diff --git a/.changeset/brave-donkeys-invite.md b/.changeset/brave-donkeys-invite.md new file mode 100644 index 000000000..269d16764 --- /dev/null +++ b/.changeset/brave-donkeys-invite.md @@ -0,0 +1,9 @@ +--- +"executor": patch +--- + +Sanitize URLs in OAuth error messages. The token-endpoint HTTP summary and the +"no authorization-server metadata found" probe error now strip query-string and +userinfo credentials from the URL they quote, and token-endpoint body redaction +covers authorization codes, PKCE verifiers, device codes, and assertions in both +JSON and form-encoded responses. diff --git a/.changeset/hungry-parrots-repeat.md b/.changeset/hungry-parrots-repeat.md new file mode 100644 index 000000000..1abd6ca30 --- /dev/null +++ b/.changeset/hungry-parrots-repeat.md @@ -0,0 +1,8 @@ +--- +"executor": patch +--- + +Model a missing OAuth client secret as `null` instead of the empty string. A +public/PKCE client and a confidential one are now told apart by an explicit +presence check, and registering a client with an empty-string secret is +rejected rather than silently treated as public. diff --git a/.changeset/lucky-mirrors-listen.md b/.changeset/lucky-mirrors-listen.md new file mode 100644 index 000000000..85bbb960d --- /dev/null +++ b/.changeset/lucky-mirrors-listen.md @@ -0,0 +1,20 @@ +--- +"executor": patch +--- + +A credential pasted into the connect flow is now `Redacted` from the +moment the HTTP payload decodes, so it cannot reach a log, a span attribute, or +an error payload between the request body and the credential provider. The +`connections.create` and `connections.validate` payloads decode `value` and each +entry of `values` through a bidirectional codec, so the same schema still +encodes on the browser client that sends the credential. + +Inputs are widened, not narrowed: `ConnectionValueInput` and +`ConnectionInputOrigin` accept `string | Redacted` for `value` / +`values`, so the documented plain-string `connections.create({ value })` calls +keep compiling. Outputs are unchanged — the guarantee already lives on +`CredentialProvider.get`. + +Callers that build the HTTP payload directly (rather than going through the SDK) +must wrap pasted secrets with `Redacted.make`; the payload schema unwraps them +while encoding the request body. diff --git a/.changeset/olive-herons-wander.md b/.changeset/olive-herons-wander.md new file mode 100644 index 000000000..e9fb91950 --- /dev/null +++ b/.changeset/olive-herons-wander.md @@ -0,0 +1,10 @@ +--- +"executor": patch +--- + +`CredentialProvider.get` now returns `Redacted | null`, so a resolved +credential cannot reach a log, span attribute, or error message without an +explicit unwrap. `set` accepts `string | Redacted`, so existing callers +that pass a plain string are unaffected. Custom providers must wrap reads with +`Redacted.make` and unwrap writes with the new `credentialValueToWrite` helper +at their serialization line. diff --git a/.changeset/olive-pandas-remain.md b/.changeset/olive-pandas-remain.md new file mode 100644 index 000000000..42f7af268 --- /dev/null +++ b/.changeset/olive-pandas-remain.md @@ -0,0 +1,29 @@ +--- +"executor": minor +--- + +Resolved credentials are `Redacted` end to end, and the unwrap points +are now lint-enforced. A new `executor/no-redacted-unwrap` rule flags every +`Redacted.value`, so each one is either an allowlisted boundary carrying a +`-- boundary:` reason or a bug. The allowlisted set is small and deliberate: the +provider serialization line, `renderAuthPlacements`, the oauth4webapi call +boundary, the `oauth_session.pkce_verifier` column, the MCP SDK's +`OAuthClientProvider`, the API-key one-time display, and the wire codecs. + +Breaking for plugin authors. `CredentialProvider.get` returns +`Redacted | null`, and `ToolInvocationCredential`'s `value` / `values` +entries are `Redacted | null`. Inputs are widened rather than narrowed — +`set`, `setDefault`, and the OAuth secret inputs all accept +`string | Redacted` — so existing writes keep compiling. Tool authoring +schemas are untouched: `Redacted` never appears on a tool's input or output. + +A plugin that reads a credential directly must unwrap at the point the value +goes on the wire. A missed unwrap does not throw — it serializes the literal +`""` — so cover write paths with a test that asserts the persisted +bytes. + +Also fixes two required secrets that silently defaulted to the empty string: the +billing route now returns 503 `billing_not_configured` when `AUTUMN_SECRET_KEY` +is unset instead of calling Autumn with an empty key, and the cloud plugin +config no longer hands the WorkOS Vault plugin empty credentials — omitting them +lets the plugin fail at startup, where a missing binding belongs. diff --git a/.changeset/plenty-jars-repeat.md b/.changeset/plenty-jars-repeat.md new file mode 100644 index 000000000..7691f8a0e --- /dev/null +++ b/.changeset/plenty-jars-repeat.md @@ -0,0 +1,13 @@ +--- +"executor": patch +--- + +Close the remaining credential leaks at the telemetry export seam. Span events +and status messages are now scrubbed alongside URL attributes, so the +exception messages, stack traces, and pretty-printed causes every failed span +carries no longer ship query-string credentials, and unbounded stack text is +capped. The browser tracer applies the same scrub as the server (previously it +exported unredacted), the tracer's redacted-header list is widened past +Effect's four defaults to cover the header names an integration's auth +placement can mint, and the OpenAPI invoke span's `base_url` is sanitized like +the mcp and graphql endpoints already were. diff --git a/.changeset/quiet-moons-shave.md b/.changeset/quiet-moons-shave.md new file mode 100644 index 000000000..d98c84901 --- /dev/null +++ b/.changeset/quiet-moons-shave.md @@ -0,0 +1,23 @@ +--- +"executor": patch +--- + +OAuth token material is now carried as `Redacted`. `OAuth2TokenResponse` +returns `access_token` and `refresh_token` wrapped, the PKCE code verifier and +the RFC 6749 `state` are wrapped for their in-memory life, and a DCR response's +`client_secret` / `registration_access_token` are wrapped as they are decoded. +The unwraps are confined to the oauth4webapi call boundary, the credential +provider write, and the `oauth_session.pkce_verifier` column. + +Inputs are widened, not narrowed: `clientSecret`, `code`, `codeVerifier`, +`refreshToken`, and the DCR `initialAccessToken` all accept +`string | Redacted`, so existing callers are unaffected. `OAuthClient. +clientSecret` accepts either as well; `null` remains the only spelling of +"public / PKCE client", and `oauthClientSecretFromInput` now tests emptiness on +the unwrapped value, since every `Redacted` is truthy. + +Also fixes a leak this surfaced: `OAuth2Error.cause` carried the token +endpoint's parsed error body verbatim, so a rejected grant republished the +credentials the endpoint echoed back into anything that serialized the failure +(a log line, an error payload). The cause is now redacted structurally, keeping +the RFC 6749 `error` / `error_description` diagnostics. diff --git a/.changeset/tidy-falcons-gather.md b/.changeset/tidy-falcons-gather.md new file mode 100644 index 000000000..698055ffe --- /dev/null +++ b/.changeset/tidy-falcons-gather.md @@ -0,0 +1,23 @@ +--- +"executor": patch +--- + +Resolved connection credentials are now carried as `Redacted` from the +provider all the way to the plugin contract, so a credential cannot reach a log, +a span attribute, a pool key, or an error payload without an explicit unwrap. + +Breaking for out-of-tree plugins. On `ToolInvocationCredential`, `value` and +every entry of `values` are now `Redacted | null`; `ctx.connections. +resolveValue`, `resolveTools`'s `getValue` / `getValues`, and `ctx.providers.get` +return `Redacted | null`. `ctx.providers.setDefault` accepts +`string | Redacted`, so existing writes are unaffected. + +Plugins that render credentials onto an HTTP request need no change: unwrapping +happens inside `renderAuthPlacements` in `@executor-js/sdk/http-auth`, which is +the intended boundary. A plugin that reads a value directly must call +`Redacted.value` at the point the value goes on the wire — a missed unwrap does +not throw, it serializes the literal `""`. + +New in `@executor-js/sdk`: `makeCredentialScrubber`, which strips a connection's +resolved values out of upstream error text and payloads. The OpenAPI invoke and +health-check paths and the MCP health check now scrub through it. diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index 9c7d8caf1..e9ce478e2 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -22,6 +22,7 @@ "executor/no-promise-reject": "error", "executor/no-raw-durable-object-id": "error", "executor/no-raw-fetch": "error", + "executor/no-redacted-unwrap": "error", "executor/no-redundant-primitive-cast": "error", "executor/no-redundant-error-factory": "error", "executor/no-schema-class": "error", diff --git a/apps/cloud/executor.config.ts b/apps/cloud/executor.config.ts index 5f2759f89..c9540e480 100644 --- a/apps/cloud/executor.config.ts +++ b/apps/cloud/executor.config.ts @@ -155,8 +155,12 @@ export default defineExecutorConfig({ allowPrivateGitHosts: allowLocalNetwork === true, }), toolkitsPlugin({ activeToolkitSlug }), + // Neither credentials nor an injected client is a misconfiguration, not a + // mode: passing empty ones would build a vault client that 401s on the + // first credential read. Omitting both lets the plugin fail at startup, + // which is where a missing WORKOS_* binding belongs. workosVaultPlugin({ - credentials: workosCredentials ?? { apiKey: "", clientId: "" }, + ...(workosCredentials ? { credentials: workosCredentials } : {}), ...(workosVaultClient ? { client: workosVaultClient } : {}), }), ] as const, diff --git a/apps/cloud/src/api/protected-api-key-auth.node.test.ts b/apps/cloud/src/api/protected-api-key-auth.node.test.ts index 9d9334bf9..50949c855 100644 --- a/apps/cloud/src/api/protected-api-key-auth.node.test.ts +++ b/apps/cloud/src/api/protected-api-key-auth.node.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect, Layer, Redacted } from "effect"; import { ApiKeyService } from "../auth/api-keys"; import { UserStoreService } from "../auth/context"; @@ -8,17 +8,23 @@ import { resolveProtectedPrincipal } from "./protected"; const createdAt = new Date("2026-01-01T00:00:00.000Z"); +// Records every key the resolver hands the service, so the test can assert the +// bytes that crossed the seam rather than only the principal that came back. +const seenKeys: string[] = []; + const stubApiKeys = Layer.succeed(ApiKeyService)({ - validate: (value: string) => - Effect.succeed( - value === "valid_user_key" + validate: (value: Redacted.Redacted) => + Effect.sync(() => { + const key = Redacted.value(value); + seenKeys.push(key); + return key === "valid_user_key" ? { accountId: "user_123", organizationId: "org_123", keyId: "api_key_123", } - : null, - ), + : null; + }), listUserKeys: () => Effect.succeed([]), createUserKey: () => Effect.die("protected API auth test does not create API keys"), revokeUserKey: () => Effect.void, @@ -78,12 +84,18 @@ const run = (request: Request) => describe("protected API key auth", () => { it.effect("resolves a valid bearer API key into protected identity", () => Effect.gen(function* () { + seenKeys.length = 0; const identity = yield* run( new Request("https://executor.test/api/tools", { headers: { authorization: "Bearer valid_user_key" }, }), ); + // The key the header carried reached the service intact — a `Redacted` + // that lost its bytes would render "" here and reject every + // caller, and a missed wrap would not be visible in the principal alone. + expect(seenKeys).toEqual(["valid_user_key"]); + expect(identity).toEqual({ accountId: "user_123", organizationId: "org_123", @@ -117,4 +129,38 @@ describe("protected API key auth", () => { }); }), ); + + it.effect("keeps the header-shape rejections distinct and never validates them", () => + Effect.gen(function* () { + seenKeys.length = 0; + + // A non-Bearer scheme and an empty Bearer token are separate machine + // codes; neither is a credential, so neither reaches the service. + const notBearer = yield* Effect.flip( + run( + new Request("https://executor.test/api/tools", { + headers: { authorization: "Basic valid_user_key" }, + }), + ), + ); + expect(notBearer).toMatchObject({ + _tag: "Unauthorized", + code: "invalid_authorization_header", + }); + + // `Headers` strips trailing ASCII whitespace, so `"Bearer "` would arrive + // as a bare `"Bearer"` and read as NotBearer. A non-breaking space + // survives normalization and is what reaches the empty-token branch. + const empty = yield* Effect.flip( + run( + new Request("https://executor.test/api/tools", { + headers: { authorization: `Bearer ${String.fromCharCode(0xa0)}` }, + }), + ), + ); + expect(empty).toMatchObject({ _tag: "Unauthorized", code: "invalid_api_key" }); + + expect(seenKeys).toEqual([]); + }), + ); }); diff --git a/apps/cloud/src/auth/api-keys.node.test.ts b/apps/cloud/src/auth/api-keys.node.test.ts index 6208dae37..7e80e6e04 100644 --- a/apps/cloud/src/auth/api-keys.node.test.ts +++ b/apps/cloud/src/auth/api-keys.node.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect, Layer, Redacted } from "effect"; import { ApiKeyService } from "./api-keys"; import { WorkOSClient, type WorkOSClientService } from "./workos"; +// Synthetic, and asserted by exact match, so `Redacted`'s "" +// rendering cannot pass for the real key. +const CREATED_SECRET = "synthetic-created-key"; + const stubWorkOS = (overrides: Partial) => Layer.succeed( WorkOSClient, @@ -18,7 +22,7 @@ const stubWorkOS = (overrides: Partial) => const validate = (response: unknown) => Effect.gen(function* () { const apiKeys = yield* ApiKeyService; - return yield* apiKeys.validate("test_key"); + return yield* apiKeys.validate(Redacted.make("synthetic-inbound-key")); }).pipe( Effect.provide( ApiKeyService.WorkOS.pipe( @@ -134,7 +138,7 @@ describe("ApiKeyService.WorkOS", () => { Effect.succeed({ id: "api_key_created", name: "Local CLI", - value: "sk_created", + value: CREATED_SECRET, obfuscated_value: "sk_...ated", created_at: "2026-01-01T00:00:00.000Z", updated_at: "2026-01-01T00:00:00.000Z", @@ -162,7 +166,50 @@ describe("ApiKeyService.WorkOS", () => { lastUsedAt: null, }, ]); - expect(result.created.value).toBe("sk_created"); + // The created secret is `Redacted` from the decode onward, and still + // holds the real bytes — WorkOS returns it exactly once, so a wrapper + // that lost the value would be indistinguishable from a working one until + // a customer tried the key. + expect(Redacted.isRedacted(result.created.value)).toBe(true); + expect(Redacted.value(result.created.value)).toBe(CREATED_SECRET); + + // What a log line or a span attribute would produce for the whole record. + const serialized = JSON.stringify(result.created); + expect(serialized).not.toContain(CREATED_SECRET); + expect(serialized).toContain(""); + }), + ); + + it.effect("hands WorkOS the caller's real key when validating", () => + Effect.gen(function* () { + const seen: string[] = []; + const owner = yield* Effect.gen(function* () { + const apiKeys = yield* ApiKeyService; + return yield* apiKeys.validate(Redacted.make("synthetic-inbound-key")); + }).pipe( + Effect.provide( + ApiKeyService.WorkOS.pipe( + Layer.provide( + stubWorkOS({ + validateApiKey: (value) => { + seen.push(Redacted.value(value)); + return Effect.succeed({ + apiKey: { + id: "api_key_123", + owner: { type: "user", id: "user_123", organizationId: "org_123" }, + }, + }); + }, + }), + ), + ), + ), + ); + + // The unwrap at the WorkOS boundary is deliberate: the control plane can + // only answer for the caller's actual key, not for "". + expect(seen).toEqual(["synthetic-inbound-key"]); + expect(owner?.accountId).toBe("user_123"); }), ); }); diff --git a/apps/cloud/src/auth/api-keys.ts b/apps/cloud/src/auth/api-keys.ts index 2984f5987..cd2d2e42f 100644 --- a/apps/cloud/src/auth/api-keys.ts +++ b/apps/cloud/src/auth/api-keys.ts @@ -1,4 +1,4 @@ -import { Context, Data, Effect, Layer, Option, Schema } from "effect"; +import { Context, Data, Effect, Layer, Option, Redacted, Schema } from "effect"; import { ApiKeyManagementError } from "./errors"; import { WorkOSClient } from "./workos"; @@ -20,8 +20,12 @@ export type ApiKeySummary = { readonly lastUsedAt: string | null; }; +/** A summary plus the plaintext key WorkOS returns exactly once. The secret is + * `Redacted` from the decode onwards — it is otherwise indistinguishable from + * the key's `name`, and this shape travels through the account service and out + * as an HTTP body. */ export type CreatedApiKey = ApiKeySummary & { - readonly value: string; + readonly value: Redacted.Redacted; }; export class ApiKeyValidationError extends Data.TaggedError("ApiKeyValidationError")<{ @@ -65,7 +69,9 @@ const RawCreatedApiKey = Schema.Struct({ updated_at: Schema.optional(Schema.String), lastUsedAt: Schema.optional(Schema.NullOr(Schema.String)), last_used_at: Schema.optional(Schema.NullOr(Schema.String)), - value: Schema.String, + // Decode-only: this schema reads the WorkOS create response and is never + // encoded, so `RedactedFromValue`'s forbidden encode costs nothing here. + value: Schema.RedactedFromValue(Schema.String), }); const ListApiKeysResponse = Schema.Struct({ @@ -134,7 +140,9 @@ const createdFromResponse = (value: unknown): CreatedApiKey | null => export class ApiKeyService extends Context.Service< ApiKeyService, { - readonly validate: (value: string) => Effect.Effect; + readonly validate: ( + value: Redacted.Redacted, + ) => Effect.Effect; readonly listUserKeys: (input: { readonly accountId: string; readonly organizationId: string; @@ -153,7 +161,7 @@ export class ApiKeyService extends Context.Service< Effect.gen(function* () { const workos = yield* WorkOSClient; return { - validate: (value: string) => + validate: (value) => workos.validateApiKey(value).pipe( Effect.map(ownerFromResponse), Effect.mapError((cause) => new ApiKeyValidationError({ cause })), diff --git a/apps/cloud/src/auth/bearer.node.test.ts b/apps/cloud/src/auth/bearer.node.test.ts new file mode 100644 index 000000000..953a3fea3 --- /dev/null +++ b/apps/cloud/src/auth/bearer.node.test.ts @@ -0,0 +1,75 @@ +// --------------------------------------------------------------------------- +// The single slice point for an inbound bearer credential. +// +// Both cloud bearer planes (the WorkOS api-key/session resolver and the MCP +// edge auth) route through this helper, so this is where the guarantee that a +// raw header never becomes a bare string is asserted. The header shapes are +// enumerated because each one means something different upstream — `Absent` +// falls through to the cookie session while `NotBearer` is a rejection, and +// `Empty` is a shape no call site can recover once the token is wrapped. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; +import { Redacted } from "effect"; + +import { bearerCredential, isBearerPresent, isJwtBearer } from "./bearer"; + +// Synthetic, and asserted by exact match, so "" cannot pass for it. +const TOKEN_SECRET = "synthetic-inbound-token"; + +// `Headers` strips trailing ASCII optional-whitespace, so a header written +// `"Bearer "` arrives as `"Bearer"` — the prefix no longer matches and the +// shape is NotBearer. A token that is only whitespace `Headers` preserves (a +// non-breaking space) is what actually reaches the Empty branch. +const NON_BREAKING_SPACE = String.fromCharCode(0xa0); + +const requestWith = (headers: Record) => + new Request("https://executor.test/api/tools", { headers }); + +describe("bearerCredential", () => { + it("wraps the sliced token and keeps its real bytes", () => { + const credential = bearerCredential(requestWith({ authorization: `Bearer ${TOKEN_SECRET}` })); + + expect(isBearerPresent(credential)).toBe(true); + if (!isBearerPresent(credential)) return; + expect(Redacted.isRedacted(credential.token)).toBe(true); + expect(Redacted.value(credential.token)).toBe(TOKEN_SECRET); + + // What a log line, a span attribute, or an error payload would produce. + const serialized = JSON.stringify(credential); + expect(serialized).not.toContain(TOKEN_SECRET); + expect(serialized).toContain(""); + }); + + it("trims surrounding whitespace off the token", () => { + const credential = bearerCredential(requestWith({ authorization: `Bearer ${TOKEN_SECRET} ` })); + + expect(isBearerPresent(credential)).toBe(true); + if (!isBearerPresent(credential)) return; + expect(Redacted.value(credential.token)).toBe(TOKEN_SECRET); + }); + + it("distinguishes a missing header, a non-Bearer scheme, and an empty token", () => { + expect(bearerCredential(requestWith({}))).toEqual({ _tag: "Absent" }); + expect(bearerCredential(requestWith({ authorization: `Basic ${TOKEN_SECRET}` }))).toEqual({ + _tag: "NotBearer", + }); + // A bare scheme with nothing after it: the trailing space is stripped + // before this code sees it, so it never matches the prefix. + expect(bearerCredential(requestWith({ authorization: "Bearer " }))).toEqual({ + _tag: "NotBearer", + }); + expect( + bearerCredential(requestWith({ authorization: `Bearer ${NON_BREAKING_SPACE}` })), + ).toEqual({ _tag: "Empty" }); + }); +}); + +describe("isJwtBearer", () => { + it("splits three-segment access tokens from api keys without unwrapping at the call site", () => { + expect(isJwtBearer(Redacted.make("header.payload.signature"))).toBe(true); + expect(isJwtBearer(Redacted.make(TOKEN_SECRET))).toBe(false); + expect(isJwtBearer(Redacted.make("header.payload"))).toBe(false); + expect(isJwtBearer(Redacted.make("a.b.c.d"))).toBe(false); + }); +}); diff --git a/apps/cloud/src/auth/bearer.ts b/apps/cloud/src/auth/bearer.ts index ab0a8cfaf..80e768171 100644 --- a/apps/cloud/src/auth/bearer.ts +++ b/apps/cloud/src/auth/bearer.ts @@ -1,9 +1,47 @@ // --------------------------------------------------------------------------- -// Bearer token parsing — single-sourced HTTP `Authorization: Bearer …` prefix. +// Bearer credential parsing — single-sourced HTTP `Authorization: Bearer …` +// prefix, and the ONE place the token is sliced off the header. // // Shared by every cloud credential path that splits a bearer token off the // `Authorization` header (the WorkOS api-key/session resolver and the MCP edge // auth). Defined once so the literal cannot drift. +// +// The slice hands back a `Redacted`, so no call site ever holds the inbound +// credential as a bare string. The header shapes the callers tell apart are +// enumerated as outcomes rather than folded into a nullable string: `Absent` +// falls through to the cookie session, `NotBearer` and `Empty` are distinct +// rejections, and `Redacted.make("")` is truthy — a caller could not test an +// empty token for itself once it is wrapped. // --------------------------------------------------------------------------- +import { Predicate, Redacted } from "effect"; + export const BEARER_PREFIX = "Bearer "; + +export type BearerCredential = + | { readonly _tag: "Absent" } + | { readonly _tag: "NotBearer" } + | { readonly _tag: "Empty" } + | { readonly _tag: "Present"; readonly token: Redacted.Redacted }; + +/** Narrows to the one outcome that carries a token, so a call site reaches + * `token` only after proving it exists. */ +export const isBearerPresent = Predicate.isTagged("Present"); + +/** The bearer credential an inbound request carries, wrapped as it is sliced. */ +export const bearerCredential = (request: Request): BearerCredential => { + const header = request.headers.get("authorization"); + if (!header) return { _tag: "Absent" }; + if (!header.startsWith(BEARER_PREFIX)) return { _tag: "NotBearer" }; + const token = header.slice(BEARER_PREFIX.length).trim(); + if (!token) return { _tag: "Empty" }; + return { _tag: "Present", token: Redacted.make(token) }; +}; + +/** Whether a bearer credential is a WorkOS access-token JWT (three dot-separated + * segments) rather than an API key — the discriminator BOTH bearer planes + * dispatch on. Reads the plaintext to count segments and retains none of it, + * so the call sites stay `Redacted`-only. */ +export const isJwtBearer = (token: Redacted.Redacted): boolean => + // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the shape of the credential is the dispatch key; only the segment count escapes + Redacted.value(token).split(".").length === 3; diff --git a/apps/cloud/src/auth/middleware-live.ts b/apps/cloud/src/auth/middleware-live.ts index 16ac3a4ff..043c3fc58 100644 --- a/apps/cloud/src/auth/middleware-live.ts +++ b/apps/cloud/src/auth/middleware-live.ts @@ -27,6 +27,7 @@ export const SessionAuthLive = Layer.effect( cookie: (httpEffect, { credential }) => Effect.gen(function* () { const result = yield* workos + // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the WorkOS SDK unseals the cookie itself and takes the sealed string .authenticateSealedSession(Redacted.value(credential)) .pipe(Effect.orElseSucceed(() => null)); @@ -52,6 +53,7 @@ export const SessionAuthLive = Layer.effect( }, }; + // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: `Session.sealedSession` is re-set as a Set-Cookie header, so it holds the sealed string const session = sessionFromSealed(result, Redacted.value(credential)); const response = yield* httpEffect.pipe( Effect.provideService(SessionContext, session), @@ -74,6 +76,7 @@ export const OrgAuthLive = Layer.effect( cookie: (httpEffect, { credential }) => Effect.gen(function* () { const result = yield* workos + // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the WorkOS SDK unseals the cookie itself and takes the sealed string .authenticateSealedSession(Redacted.value(credential)) .pipe(Effect.orElseSucceed(() => null)); @@ -85,6 +88,7 @@ export const OrgAuthLive = Layer.effect( return yield* Effect.fail(new NoOrganization()); } + // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: `Session.sealedSession` is re-set as a Set-Cookie header, so it holds the sealed string const session = sessionFromSealed(result, Redacted.value(credential)); const auth = { accountId: session.accountId, diff --git a/apps/cloud/src/auth/workos-auth-provider.ts b/apps/cloud/src/auth/workos-auth-provider.ts index 0c9477c38..3e1c0a271 100644 --- a/apps/cloud/src/auth/workos-auth-provider.ts +++ b/apps/cloud/src/auth/workos-auth-provider.ts @@ -27,7 +27,7 @@ // NOT a function-level requirement (that is what forced a forked tag before). // --------------------------------------------------------------------------- -import { Effect, Layer } from "effect"; +import { Effect, Layer, Predicate, Redacted } from "effect"; import { HttpServerResponse } from "effect/unstable/http"; import type { JWTVerifyGetKey } from "jose"; @@ -41,7 +41,7 @@ import type { FailureRenderingStrategy, IdentityFailure, Principal } from "@exec import { ApiKeyService } from "./api-keys"; import { workosApiJwtBearerConfig } from "./api-jwt-bearer"; -import { BEARER_PREFIX } from "./bearer"; +import { bearerCredential, isBearerPresent, isJwtBearer } from "./bearer"; import { authorizeOrganization, authorizeOrganizationSelector, @@ -99,11 +99,6 @@ const NO_ORGANIZATION_IN_ACCESS_TOKEN = { message: "No organization in access token", }; -// A bearer value with three dot-separated segments is a JWT (a WorkOS access -// token from the CLI device-login); anything else is treated as an API key. -// Same discriminator the MCP plane uses (`mcp/auth.ts`). -const looksLikeJwt = (token: string): boolean => token.split(".").length === 3; - /** * Resolve a WorkOS device-login (user_management) access token into a protected * `Principal`. Verifies the token's signature + expiry against the client-scoped @@ -113,9 +108,12 @@ const looksLikeJwt = (token: string): boolean => token.split(".").length === 3; * `/oauth2` tokens (different keyset, no audience), so it does NOT reuse the MCP * verifier or its JWKS, audience, and issuer. */ -const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => +const resolveJwtPrincipal = (token: Redacted.Redacted, jwt: JwtBearerConfig) => Effect.gen(function* () { - const verified = yield* verifyWorkosUserManagementToken(token, jwt.jwks).pipe( + // Unwrapped for the signature check itself — jose verifies the token's own + // bytes; nothing derived from them is retained. + // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: jose verifies the token's own bytes + const verified = yield* verifyWorkosUserManagementToken(Redacted.value(token), jwt.jwks).pipe( Effect.catchTag("McpJwtVerificationError", (error) => Effect.fail( error.reason === "system" @@ -154,21 +152,19 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => */ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | null = null) => Effect.gen(function* () { - const authHeader = request.headers.get("authorization"); - if (!authHeader) return null; - - if (!authHeader.startsWith(BEARER_PREFIX)) { + const credential = bearerCredential(request); + if (Predicate.isTagged(credential, "Absent")) return null; + if (Predicate.isTagged(credential, "NotBearer")) { return yield* new Unauthorized(INVALID_AUTHORIZATION_HEADER); } + if (!isBearerPresent(credential)) return yield* new Unauthorized(INVALID_API_KEY); - const value = authHeader.slice(BEARER_PREFIX.length).trim(); - if (!value) return yield* new Unauthorized(INVALID_API_KEY); - - if (jwt && looksLikeJwt(value)) return yield* resolveJwtPrincipal(value, jwt); + const { token } = credential; + if (jwt && isJwtBearer(token)) return yield* resolveJwtPrincipal(token, jwt); const apiKeys = yield* ApiKeyService; const principal = yield* apiKeys - .validate(value) + .validate(token) .pipe( Effect.catchTag("ApiKeyValidationError", () => Effect.fail(new Unavailable(API_KEY_VALIDATION_UNAVAILABLE)), diff --git a/apps/cloud/src/auth/workos.ts b/apps/cloud/src/auth/workos.ts index 18fb74bae..c7f76a410 100644 --- a/apps/cloud/src/auth/workos.ts +++ b/apps/cloud/src/auth/workos.ts @@ -3,7 +3,7 @@ // --------------------------------------------------------------------------- import { env } from "cloudflare:workers"; -import { Context, Data, Effect, Layer, Option, Predicate, Schema } from "effect"; +import { Context, Data, Effect, Layer, Option, Predicate, Redacted, Schema } from "effect"; import { GeneratePortalLinkIntent, WorkOS } from "@workos-inc/node/worker"; import { defaults as ironDefaults, unseal as unsealIron } from "iron-webcrypto"; import { decodeJwt, jwtVerify } from "jose"; @@ -515,9 +515,15 @@ const make = Effect.gen(function* () { * organization-owned key types, while WorkOS's API also returns user-owned * keys. Keep this boundary unknown and decode the precise app shape in * auth/api-keys.ts. + * + * Unwrapping here is the deliberate boundary: WorkOS has to receive the + * caller's actual key to answer whether it is valid. */ - validateApiKey: (value: string) => - use((wos) => wos.apiKeys.validateApiKey({ value }) as Promise), + validateApiKey: (value: Redacted.Redacted) => + use( + // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: WorkOS has to receive the caller's actual key to answer whether it is valid + (wos) => wos.apiKeys.validateApiKey({ value: Redacted.value(value) }) as Promise, + ), listUserApiKeys: (userId: string, organizationId: string) => use(async (wos) => { diff --git a/apps/cloud/src/extensions/billing/route.ts b/apps/cloud/src/extensions/billing/route.ts index 33e3d580e..52b2e1f30 100644 --- a/apps/cloud/src/extensions/billing/route.ts +++ b/apps/cloud/src/extensions/billing/route.ts @@ -76,6 +76,20 @@ const handler = Effect.gen(function* () { ) : undefined; + // An empty secret key is not a spelling of "no billing backend": autumn-js + // would accept it and every upstream call would 401, which surfaces as a + // billing failure rather than the misconfiguration it is. `AutumnService` + // degrades to a no-op tracker on the same missing key; this route has no + // no-op mode, so it fails loudly instead. + const secretKey = env.AUTUMN_SECRET_KEY; + if (!secretKey) { + return yield* new HttpResponseError({ + status: 503, + code: "billing_not_configured", + message: "Billing is not configured", + }); + } + const { statusCode, response } = yield* Effect.promise(() => autumnHandler({ request: { @@ -89,7 +103,7 @@ const handler = Effect.gen(function* () { email: session.email, }, clientOptions: { - secretKey: env.AUTUMN_SECRET_KEY ?? "", + secretKey, // autumn-js's handler reads `baseURL` to override the Autumn endpoint // (not `serverURL`, which it silently ignores). Without this, a non-prod // AUTUMN_API_URL (the e2e emulator, a self-hosted Autumn) is dropped and diff --git a/apps/cloud/src/mcp/auth-bearer.test.ts b/apps/cloud/src/mcp/auth-bearer.test.ts new file mode 100644 index 000000000..a616b6472 --- /dev/null +++ b/apps/cloud/src/mcp/auth-bearer.test.ts @@ -0,0 +1,104 @@ +// --------------------------------------------------------------------------- +// The MCP edge's bearer dispatch, over the shared slice helper. +// +// The api-key branch is what this covers: it is the one that hands a caller's +// credential to the WorkOS control plane, so the bytes crossing that seam are +// the assertion. The JWT branch verifies against the module-scope remote JWKS +// and is covered by `mcp-auth.node.test.ts` at the verifier itself. +// +// The outcome stamping is asserted only by its absence of change — this plane +// stamps enumerated outcomes and nothing derived from the credential. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Redacted } from "effect"; + +import { ApiKeyService } from "../auth/api-keys"; +import { McpAuth, McpAuthLive } from "./auth"; + +// Synthetic, and asserted by exact match, so "" cannot pass for it. +const API_KEY_SECRET = "synthetic-mcp-api-key"; + +const seenKeys: string[] = []; + +const stubApiKeys = Layer.succeed(ApiKeyService)({ + validate: (value: Redacted.Redacted) => + Effect.sync(() => { + const key = Redacted.value(value); + seenKeys.push(key); + return key === API_KEY_SECRET + ? { accountId: "user_123", organizationId: "org_123", keyId: "api_key_123" } + : null; + }), + listUserKeys: () => Effect.succeed([]), + createUserKey: () => Effect.die("MCP bearer test does not create API keys"), + revokeUserKey: () => Effect.void, +}); + +const verify = (headers: Record) => + Effect.gen(function* () { + const auth = yield* McpAuth; + return yield* auth.verifyBearer(new Request("https://executor.sh/mcp", { headers })); + }).pipe(Effect.provide(McpAuthLive.pipe(Layer.provide(stubApiKeys)))); + +describe("MCP bearer dispatch", () => { + it.effect("hands the api-key branch the caller's real key", () => + Effect.gen(function* () { + seenKeys.length = 0; + const result = yield* verify({ authorization: `Bearer ${API_KEY_SECRET}` }); + + // A wrapper that lost its bytes would render "" here and reject + // every MCP client, which the authorized result alone would not reveal. + expect(seenKeys).toEqual([API_KEY_SECRET]); + expect(result).toEqual({ + _tag: "Authorized", + token: { accountId: "user_123", organizationId: "org_123" }, + }); + }), + ); + + it.effect("rejects an unknown api key without leaking it into the result", () => + Effect.gen(function* () { + seenKeys.length = 0; + const result = yield* verify({ authorization: "Bearer synthetic-unknown-key" }); + + expect(seenKeys).toEqual(["synthetic-unknown-key"]); + expect(result).toEqual({ + _tag: "Unauthorized", + reason: "invalid_token", + description: "The API key is invalid", + }); + expect(JSON.stringify(result)).not.toContain("synthetic-unknown-key"); + }), + ); + + it.effect("treats a missing and a non-Bearer header alike, and validates neither", () => + Effect.gen(function* () { + seenKeys.length = 0; + const missing = yield* verify({}); + const notBearer = yield* verify({ authorization: `Basic ${API_KEY_SECRET}` }); + + // The MCP plane accepts no other scheme, so both are the same + // "no credential" challenge. + expect(missing).toEqual({ _tag: "Unauthorized", reason: "missing_bearer" }); + expect(notBearer).toEqual({ _tag: "Unauthorized", reason: "missing_bearer" }); + expect(seenKeys).toEqual([]); + }), + ); + + it.effect("rejects an empty bearer token as invalid rather than missing", () => + Effect.gen(function* () { + seenKeys.length = 0; + // `Headers` strips trailing ASCII whitespace, so `"Bearer "` would arrive + // as a bare `"Bearer"`. A non-breaking space survives normalization. + const result = yield* verify({ authorization: `Bearer ${String.fromCharCode(0xa0)}` }); + + expect(result).toEqual({ + _tag: "Unauthorized", + reason: "invalid_token", + description: "The bearer token is invalid", + }); + expect(seenKeys).toEqual([]); + }), + ); +}); diff --git a/apps/cloud/src/mcp/auth.ts b/apps/cloud/src/mcp/auth.ts index 531fd5cba..ddc3a3f20 100644 --- a/apps/cloud/src/mcp/auth.ts +++ b/apps/cloud/src/mcp/auth.ts @@ -10,11 +10,11 @@ // --------------------------------------------------------------------------- import { env } from "cloudflare:workers"; -import { Context, Effect, Layer, Predicate } from "effect"; +import { Context, Effect, Layer, Predicate, Redacted } from "effect"; import { createCachedRemoteJWKSet } from "../auth/jwks-cache"; import { ApiKeyService } from "../auth/api-keys"; -import { BEARER_PREFIX } from "../auth/bearer"; +import { bearerCredential, isBearerPresent, isJwtBearer } from "../auth/bearer"; import { authorizeOrganization } from "../auth/organization"; import { UserStoreService, makeUserStoreLayer } from "../auth/context"; import { CoreSharedServices } from "../auth/workos"; @@ -176,8 +176,11 @@ export class McpOrganizationAuth extends Context.Service< } >()("@executor-js/cloud/McpOrganizationAuth") {} -const verifyJwt = (token: string) => - verifyWorkOSMcpAccessToken(token, jwks, { +// Unwrapped for the signature check itself — jose verifies the token's own +// bytes; nothing derived from them is retained. +const verifyJwt = (token: Redacted.Redacted) => + // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: jose verifies the token's own bytes + verifyWorkOSMcpAccessToken(Redacted.value(token), jwks, { issuer: AUTHKIT_DOMAIN, audience: WORKOS_CLIENT_ID, }); @@ -222,14 +225,14 @@ export const McpOrganizationAuthLive = Layer.succeed(McpOrganizationAuth)({ ), }); -const looksLikeJwt = (token: string): boolean => token.split(".").length === 3; - export const McpAuthLive = Layer.effect( McpAuth, Effect.gen(function* () { const apiKeys = yield* ApiKeyService; - const verifyApiKey = Effect.fn("mcp.auth.verify_api_key")(function* (token: string) { + const verifyApiKey = Effect.fn("mcp.auth.verify_api_key")(function* ( + token: Redacted.Redacted, + ) { const principal = yield* apiKeys.validate(token).pipe( Effect.catchTag("ApiKeyValidationError", (error) => Effect.fail( @@ -259,7 +262,9 @@ export const McpAuthLive = Layer.effect( }); }); - const verifyJwtBearer = Effect.fn("mcp.auth.verify_jwt_bearer")(function* (token: string) { + const verifyJwtBearer = Effect.fn("mcp.auth.verify_jwt_bearer")(function* ( + token: Redacted.Redacted, + ) { const verified = yield* verifyJwt(token).pipe( Effect.catchTag("McpJwtVerificationError", (error) => { if (error.reason === "system") return Effect.fail(error); @@ -293,14 +298,21 @@ export const McpAuthLive = Layer.effect( return { verifyBearer: Effect.fn("mcp.auth.verify_bearer")(function* (request) { - const authHeader = request.headers.get("authorization"); - if (!authHeader?.startsWith(BEARER_PREFIX)) { + const credential = bearerCredential(request); + // A non-Bearer header is the same "no MCP credential" outcome a missing + // header is — the MCP plane accepts no other scheme. + if ( + Predicate.isTagged(credential, "Absent") || + Predicate.isTagged(credential, "NotBearer") + ) { yield* Effect.annotateCurrentSpan({ "mcp.auth.outcome": "missing_bearer" }); return mcpUnauthorized("missing_bearer"); } - const token = authHeader.slice(BEARER_PREFIX.length).trim(); - if (!token) return mcpUnauthorized("invalid_token", "The bearer token is invalid"); - return yield* looksLikeJwt(token) ? verifyJwtBearer(token) : verifyApiKey(token); + if (!isBearerPresent(credential)) { + return mcpUnauthorized("invalid_token", "The bearer token is invalid"); + } + const { token } = credential; + return yield* isJwtBearer(token) ? verifyJwtBearer(token) : verifyApiKey(token); }), }; }), diff --git a/apps/cloud/src/observability/oauth-callback-telemetry.test.ts b/apps/cloud/src/observability/oauth-callback-telemetry.test.ts index d96e905ef..9c4102f38 100644 --- a/apps/cloud/src/observability/oauth-callback-telemetry.test.ts +++ b/apps/cloud/src/observability/oauth-callback-telemetry.test.ts @@ -102,7 +102,21 @@ describe("oauth callback telemetry", () => { const serverSpan = spans.find((span) => span.name.startsWith("http.server")); expect(serverSpan).toBeDefined(); - const serialized = JSON.stringify(spans.map((span) => span.attributes)); + // Every surface that leaves the isolate, not just the attributes: a + // failed span carries the grant a second time through its exception + // events and status message (Effect's tracer logger and the OTel bridge's + // recordException), which an attribute-only assertion cannot see. Named + // fields rather than the whole `ReadableSpan` because the SDK's span + // objects hold a back-reference to their processor and do not serialize. + const serialized = JSON.stringify( + spans.map((span) => ({ + name: span.name, + attributes: span.attributes, + events: span.events, + status: span.status, + links: span.links, + })), + ); expect(serialized).not.toContain(CODE); expect(serialized).not.toContain(STATE); diff --git a/apps/cloud/src/observability/redact-span-urls.test.ts b/apps/cloud/src/observability/redact-span-urls.test.ts index d48a07e92..69c6981a0 100644 --- a/apps/cloud/src/observability/redact-span-urls.test.ts +++ b/apps/cloud/src/observability/redact-span-urls.test.ts @@ -1,16 +1,21 @@ +// --------------------------------------------------------------------------- +// The OTel span-processor seam. The redaction POLICY is covered by +// `packages/core/sdk/src/span-redaction.test.ts`; what is under test here is +// that the processor reaches every surface a span ships credentials on — +// attributes, events, and `status.message` — before the exporter sees it. +// --------------------------------------------------------------------------- + import { describe, expect, it } from "@effect/vitest"; +import { SpanStatusCode } from "@opentelemetry/api"; import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor, type ReadableSpan, + type Span, } from "@opentelemetry/sdk-trace-base"; -import { - redactSpanUrlAttributes, - STRIPPED_QUERY_ATTRIBUTE, - UrlRedactingSpanProcessor, -} from "./redact-span-urls"; +import { STRIPPED_QUERY_ATTRIBUTE, UrlRedactingSpanProcessor } from "./redact-span-urls"; // Synthetic placeholders only — never a real authorization code or state. const CODE = "synthetic-authorization-code"; @@ -18,89 +23,26 @@ const STATE = "synthetic-csrf-state"; const callbackUrl = `https://app.test/api/oauth/callback?code=${CODE}&state=${STATE}&domain=example.test`; -/** Ends one real SDK span carrying `attributes` through the redacting - * processor, and returns what the exporter actually received. Using the real - * provider (rather than a hand-built span) exercises the `onEnding` → `onEnd` - * hook sequence exactly as production does. */ -const exportSpanWith = (attributes: Record): ReadableSpan | undefined => { +/** Ends one real SDK span through the redacting processor and returns what the + * exporter actually received. Using the real provider (rather than a + * hand-built span) exercises the `onEnding` → `onEnd` hook sequence exactly as + * production does. */ +const exportSpan = (record: (span: Span) => void): ReadableSpan | undefined => { const exporter = new InMemorySpanExporter(); const provider = new BasicTracerProvider({ spanProcessors: [new UrlRedactingSpanProcessor(new SimpleSpanProcessor(exporter))], }); - const span = provider.getTracer("test").startSpan("http.server GET"); - span.setAttributes(attributes); + const span = provider.getTracer("test").startSpan("http.server GET") as Span; + record(span); span.end(); return exporter.getFinishedSpans()[0]; }; -describe("redactSpanUrlAttributes", () => { - it("strips the authorization code and state from url.full and url.query", () => { - const attributes: Record = { - "url.full": callbackUrl, - "url.query": `code=${CODE}&state=${STATE}&domain=example.test`, - "url.path": "/api/oauth/callback", - "http.request.method": "GET", - }; - - const stripped = redactSpanUrlAttributes(attributes); - - expect(stripped).toEqual(["code", "state"]); - expect(JSON.stringify(attributes)).not.toContain(CODE); - expect(JSON.stringify(attributes)).not.toContain(STATE); - // Route-level visibility is preserved. - expect(attributes["url.path"]).toBe("/api/oauth/callback"); - expect(attributes["url.full"]).toBe("https://app.test/api/oauth/callback?domain=example.test"); - expect(attributes["url.query"]).toBe("domain=example.test"); - }); - - it("strips a code nested inside the login redirect's returnTo parameter", () => { - // `/login` is not an app-owned path, so its span comes from the worker - // boundary — the callback query rides along inside `returnTo` - // (auth/return-to.ts + the sign-in redirect in start.ts). - const returnTo = encodeURIComponent(`/api/oauth/callback?code=${CODE}&state=${STATE}`); - const attributes: Record = { - "url.full": `https://app.test/login?returnTo=${returnTo}`, - "url.query": `returnTo=${returnTo}`, - }; - - const stripped = redactSpanUrlAttributes(attributes); - - expect(stripped).toEqual(["returnTo.code", "returnTo.state"]); - expect(JSON.stringify(attributes)).not.toContain(CODE); - expect(JSON.stringify(attributes)).not.toContain(STATE); - expect(String(attributes["url.full"])).toContain("%2Fapi%2Foauth%2Fcallback"); - }); - - it("leaves a span with no sensitive parameters untouched", () => { - const attributes: Record = { - "url.full": "https://app.test/api/integrations?owner=org", - "url.query": "owner=org", - "url.path": "/api/integrations", - }; - - expect(redactSpanUrlAttributes(attributes)).toEqual([]); - expect(attributes["url.full"]).toBe("https://app.test/api/integrations?owner=org"); - expect(attributes["url.query"]).toBe("owner=org"); - }); - - it("strips the other sensitive OAuth parameters", () => { - const attributes: Record = { - "url.query": - "id_token=synthetic-id-token&session_state=synthetic-session&error_description=synthetic-detail&error=access_denied", - }; - - expect(redactSpanUrlAttributes(attributes)).toEqual([ - "error_description", - "id_token", - "session_state", - ]); - // `error` is an enumerable code, not a secret — it stays. - expect(attributes["url.query"]).toBe("error=access_denied"); - }); -}); +const exportSpanWith = (attributes: Record): ReadableSpan | undefined => + exportSpan((span) => span.setAttributes(attributes)); describe("UrlRedactingSpanProcessor", () => { - it("scrubs the span before the exporter sees it", () => { + it("scrubs the span attributes before the exporter sees them", () => { const exported = exportSpanWith({ "url.full": callbackUrl, "url.query": `code=${CODE}&state=${STATE}`, @@ -114,6 +56,20 @@ describe("UrlRedactingSpanProcessor", () => { expect(exported?.attributes[STRIPPED_QUERY_ATTRIBUTE]).toBe("code,state"); }); + it("scrubs a url.full stamped from a relative request", () => { + // Not every seam stamps an absolute URL: a relative `url.full` has no + // origin for `URL` to parse, and it carries the same callback credential. + const exported = exportSpanWith({ + "url.full": `/api/oauth/callback?code=${CODE}&state=${STATE}&domain=example.test`, + "url.path": "/api/oauth/callback", + }); + + expect(JSON.stringify(exported?.attributes)).not.toContain(CODE); + expect(JSON.stringify(exported?.attributes)).not.toContain(STATE); + expect(exported?.attributes["url.full"]).toBe("/api/oauth/callback?domain=example.test"); + expect(exported?.attributes[STRIPPED_QUERY_ATTRIBUTE]).toBe("code,state"); + }); + it("leaves a span with no sensitive parameters unchanged", () => { const exported = exportSpanWith({ "url.full": "https://app.test/api/integrations?owner=org", @@ -123,4 +79,77 @@ describe("UrlRedactingSpanProcessor", () => { expect(exported?.attributes["url.full"]).toBe("https://app.test/api/integrations?owner=org"); expect(exported?.attributes[STRIPPED_QUERY_ATTRIBUTE]).toBeUndefined(); }); + + it("scrubs an exception event carrying the code in its message and stacktrace", () => { + // The shape `@effect/opentelemetry`'s `recordException` produces on every + // failed span — the URL scrubber never sees these, they are event + // attributes, not span attributes. + const exported = exportSpan((span) => { + span.recordException({ + name: "RequestError", + message: `POST ${callbackUrl} failed with 400`, + stack: `RequestError: POST ${callbackUrl}\n at fetch (executor.js:1:1)`, + }); + }); + + const serialized = JSON.stringify(exported?.events); + expect(serialized).not.toContain(CODE); + expect(serialized).not.toContain(STATE); + // The diagnosable part survives. + expect(serialized).toContain("/api/oauth/callback"); + expect(serialized).toContain("failed with 400"); + }); + + it("scrubs a log event whose NAME is the already-interpolated message", () => { + // Effect's default tracer logger writes the interpolated message as the + // event name and the pretty cause as an attribute. + const exported = exportSpan((span) => { + span.addEvent(`redirecting to /oauth/callback?code=${CODE}`, { + "effect.cause": `Error: GET ${callbackUrl}`, + "effect.logLevel": "ERROR", + }); + }); + + const serialized = JSON.stringify(exported?.events); + expect(serialized).not.toContain(CODE); + expect(serialized).not.toContain(STATE); + expect(exported?.events[0]?.name).toBe("redirecting to /oauth/callback"); + expect(exported?.events[0]?.attributes?.["effect.logLevel"]).toBe("ERROR"); + }); + + it("caps an unbounded stacktrace", () => { + const exported = exportSpan((span) => { + span.recordException({ + name: "DeepError", + message: "failed", + stack: `DeepError: failed\n${" at frame (executor.js:1:1)\n".repeat(2_000)}`, + }); + }); + + const stacktrace = String(exported?.events[0]?.attributes?.["exception.stacktrace"] ?? ""); + expect(stacktrace).toContain("truncated"); + expect(stacktrace.length).toBeLessThan(10_000); + }); + + it("scrubs status.message", () => { + // `@effect/opentelemetry` sets this to the first error's message, which is + // the same free text the exception event carries. + const exported = exportSpan((span) => { + span.setStatus({ code: SpanStatusCode.ERROR, message: `GET ${callbackUrl} failed` }); + }); + + expect(exported?.status.message).not.toContain(CODE); + expect(exported?.status.message).toContain("/api/oauth/callback"); + }); + + it("leaves a successful span's events and status alone", () => { + const exported = exportSpan((span) => { + span.addEvent("tool.invoked", { "tool.name": "list_things" }); + span.setStatus({ code: SpanStatusCode.OK }); + }); + + expect(exported?.events[0]?.name).toBe("tool.invoked"); + expect(exported?.events[0]?.attributes?.["tool.name"]).toBe("list_things"); + expect(exported?.status.message).toBeUndefined(); + }); }); diff --git a/apps/cloud/src/observability/redact-span-urls.ts b/apps/cloud/src/observability/redact-span-urls.ts index 12f6921f9..2bc6041ce 100644 --- a/apps/cloud/src/observability/redact-span-urls.ts +++ b/apps/cloud/src/observability/redact-span-urls.ts @@ -1,12 +1,10 @@ // --------------------------------------------------------------------------- -// URL-attribute redaction for exported spans. +// Export-seam redaction for the Workers isolates' OTel span pipeline. // -// Effect's `HttpMiddleware.tracer` stamps `url.full` and `url.query` -// unconditionally on every `http.server` span (see -// `effect/unstable/http/HttpMiddleware.ts` — it redacts URL userinfo and -// configured header names, and nothing else). `/api/oauth/callback` is an -// app-owned path, so its span carried the provider's `?code=…&state=…` -// verbatim to the trace backend on every OAuth connect. +// The decisions (which query parameters are sensitive, how free text is +// scrubbed and capped) live in `@executor-js/sdk/shared` so the browser +// isolate's exporter applies the identical policy. This file is only the OTel +// `SpanProcessor` shell around them. // // The scrub runs at the span-processor seam rather than at the route, because // this is the only chokepoint every span in the isolate must pass through on @@ -16,6 +14,17 @@ // overriding the middleware's attribute handling would mean forking Effect // internals. // +// Three surfaces carry credentials out, not one: +// +// - Attributes. `HttpMiddleware.tracer` stamps `url.full` and `url.query` +// unconditionally on every `http.server` span, so `/api/oauth/callback` +// carried the provider's `?code=…&state=…` verbatim on every OAuth connect. +// - Events. Effect's default tracer logger writes the interpolated log +// message as the EVENT NAME and the whole `effect.cause` as an event +// attribute; the OTel bridge's `recordException` writes `exception.message` +// and `exception.stacktrace` the same way. Every failed span has them. +// - `status.message`, which the bridge sets to the first error's message. +// // `url.path` is deliberately preserved: route-level visibility is what makes // these traces worth exporting at all. Only the sensitive query parameters are // removed, and their presence is recorded as a stripped-key list so a trace @@ -25,124 +34,48 @@ import type { Context } from "@opentelemetry/api"; import type { ReadableSpan, Span, SpanProcessor } from "@opentelemetry/sdk-trace-base"; -/** Query parameters that are credentials, single-use grants, or CSRF secrets. - * Matched case-insensitively against the parameter name. */ -const SENSITIVE_QUERY_KEYS: ReadonlySet = new Set([ - "access_token", - "client_secret", - "code", - "code_verifier", - "error_description", - "id_token", - "refresh_token", - "session_state", - "state", - "token", -]); - -/** Span attributes whose value is a whole URL. */ -const URL_ATTRIBUTES = ["url.full", "http.url"] as const; -const QUERY_ATTRIBUTE = "url.query"; -/** Names of the parameters removed from this span's URL attributes. Non-secret - * by construction — it is the key list, never the values. */ -export const STRIPPED_QUERY_ATTRIBUTE = "url.query.stripped_keys"; - -const isSensitive = (key: string): boolean => SENSITIVE_QUERY_KEYS.has(key.toLowerCase()); +import { + SPAN_QUERY_ATTRIBUTE, + SPAN_URL_ATTRIBUTES, + STRIPPED_QUERY_ATTRIBUTE, + redactSpanUrlAttributes, + scrubSpanText, +} from "@executor-js/sdk/shared"; -/** How deep to follow a query parameter whose value is itself a URL or path - * with a query string. Depth 2 covers the real nesting in this app — - * `/login?returnTo=%2Fapi%2Foauth%2Fcallback%3Fcode%3D…` (see - * `auth/return-to.ts` and the sign-in redirect in `start.ts`) — with headroom, - * and bounds the work per span. */ -const MAX_NESTED_DEPTH = 2; +export { STRIPPED_QUERY_ATTRIBUTE, redactSpanUrlAttributes }; -/** The query string with every sensitive parameter dropped, plus the names - * dropped. Parsed with `URLSearchParams` so encoding round-trips correctly. - * - * A parameter whose own value carries a nested query string is redacted - * recursively rather than dropped: the login redirect round-trips the whole - * OAuth callback path through `returnTo`, so the credential rides inside - * another parameter's value and a top-level key check alone would miss it. */ -const redactQuery = ( - query: string, - depth = 0, -): { readonly query: string; readonly stripped: readonly string[] } => { - const params = new URLSearchParams(query); - const stripped = new Set(); - for (const key of Array.from(new Set(params.keys()))) { - if (isSensitive(key)) { - stripped.add(key); - params.delete(key); - continue; - } - if (depth >= MAX_NESTED_DEPTH) continue; - const values = params.getAll(key); - const rewritten = values.map((value) => { - const separator = value.indexOf("?"); - if (separator === -1) return value; - const nested = redactQuery(value.slice(separator + 1), depth + 1); - for (const name of nested.stripped) stripped.add(`${key}.${name}`); - return nested.stripped.length === 0 - ? value - : `${value.slice(0, separator)}${nested.query === "" ? "" : `?${nested.query}`}`; - }); - if (rewritten.every((value, index) => value === values[index])) continue; - params.delete(key); - for (const value of rewritten) params.append(key, value); - } - return stripped.size === 0 - ? { query, stripped: [] } - : { query: params.toString(), stripped: Array.from(stripped).sort() }; -}; - -/** The URL with every sensitive query parameter dropped. Path, host, and - * scheme are untouched. Unparseable values are dropped entirely rather than - * passed through — if it cannot be parsed it cannot be proven safe. */ -const redactUrl = ( - value: string, -): { readonly url: string; readonly stripped: readonly string[] } => { - if (!URL.canParse(value)) { - const { query, stripped } = redactQuery(value); - return { url: stripped.length === 0 ? value : query, stripped }; - } - const url = new URL(value); - if (url.search === "") return { url: value, stripped: [] }; - const { query, stripped } = redactQuery(url.search.slice(1)); - if (stripped.length === 0) return { url: value, stripped: [] }; - url.search = query; - return { url: url.toString(), stripped }; -}; - -/** Rewrites the URL-bearing attributes of an in-flight span in place, dropping - * sensitive query parameters. Returns the parameter names removed. */ -export const redactSpanUrlAttributes = (attributes: Record): readonly string[] => { - const stripped = new Set(); - for (const name of URL_ATTRIBUTES) { - const value = attributes[name]; +/** Rewrite every free-text value of a span event in place: the event name + * (Effect's log message) and each string attribute. Event attributes are not + * parseable URLs — `exception.message`, `exception.stacktrace`, and + * `effect.cause` are prose that may quote one — so they go through the text + * policy (URL query parameters stripped, then capped) rather than the URL + * parser. */ +const scrubEvent = (event: { name: string; attributes?: Record }): void => { + event.name = scrubSpanText(event.name); + const attributes = event.attributes; + if (attributes === undefined) return; + for (const [key, value] of Object.entries(attributes)) { if (typeof value !== "string") continue; - const result = redactUrl(value); - for (const key of result.stripped) stripped.add(key); - if (result.stripped.length > 0) attributes[name] = result.url; - } - const query = attributes[QUERY_ATTRIBUTE]; - if (typeof query === "string") { - const result = redactQuery(query); - for (const key of result.stripped) stripped.add(key); - if (result.stripped.length > 0) attributes[QUERY_ATTRIBUTE] = result.query; + attributes[key] = scrubSpanText(value); } - return Array.from(stripped).sort(); }; -/** Wraps a span processor so every span is scrubbed of credential-bearing URL - * query parameters before the inner processor (and therefore the exporter) - * sees it. +/** Wraps a span processor so every span is scrubbed of credential-bearing + * values before the inner processor (and therefore the exporter) sees it. * - * The rewrite happens in `onEnding`, the last hook the OTel SDK calls while - * the span is still mutable (`Span.end()` runs `onEnding` before setting + * Attributes are rewritten in `onEnding`, the last hook the OTel SDK calls + * while the span is still mutable (`Span.end()` runs `onEnding` before setting * `_ended`, so `setAttribute` still applies); `onEnd` receives a frozen * `ReadableSpan`. `onEnding` is optional in the SpanProcessor interface, so * `onEnd` re-checks the attribute bag and mutates it directly as a backstop - * for any SDK path that skips the earlier hook. */ + * for any SDK path that skips the earlier hook. + * + * Events and `status.message` are scrubbed in `onEnd` only: `recordException` + * and `setStatus` both run inside `Span.end()`'s caller, so at `onEnding` time + * the events array is not yet final. `ReadableSpan` is readonly by TYPE, not + * frozen at runtime — the arrays and objects the SDK exposes are the live ones + * the exporter serializes, so mutating them here is what keeps the secret out + * of the export. */ export class UrlRedactingSpanProcessor implements SpanProcessor { constructor(private readonly inner: SpanProcessor) {} @@ -155,15 +88,18 @@ export class UrlRedactingSpanProcessor implements SpanProcessor { } onEnding(span: Span): void { - this.redact(span.attributes, (key, value) => span.setAttribute(key, value)); + this.redactAttributes(span.attributes, (key, value) => span.setAttribute(key, value)); this.inner.onEnding?.(span); } onEnd(span: ReadableSpan): void { const attributes = span.attributes as Record; - this.redact(attributes, (key, value) => { + this.redactAttributes(attributes, (key, value) => { attributes[key] = value; }); + for (const event of span.events) scrubEvent(event); + const status = span.status as { code: number; message?: string }; + if (typeof status.message === "string") status.message = scrubSpanText(status.message); this.inner.onEnd(span); } @@ -171,7 +107,7 @@ export class UrlRedactingSpanProcessor implements SpanProcessor { return this.inner.shutdown(); } - private redact( + private redactAttributes( attributes: Record, write: (key: string, value: string) => void, ): void { @@ -180,7 +116,7 @@ export class UrlRedactingSpanProcessor implements SpanProcessor { const draft: Record = { ...attributes }; const stripped = redactSpanUrlAttributes(draft); if (stripped.length === 0) return; - for (const name of [...URL_ATTRIBUTES, QUERY_ATTRIBUTE]) { + for (const name of [...SPAN_URL_ATTRIBUTES, SPAN_QUERY_ATTRIBUTE]) { const value = draft[name]; if (typeof value === "string" && value !== attributes[name]) write(name, value); } diff --git a/apps/cloud/src/observability/telemetry.ts b/apps/cloud/src/observability/telemetry.ts index 5b0c11dc1..7d094fcd0 100644 --- a/apps/cloud/src/observability/telemetry.ts +++ b/apps/cloud/src/observability/telemetry.ts @@ -45,6 +45,8 @@ import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic import { env } from "cloudflare:workers"; import { Effect, Layer } from "effect"; +import { RedactedHeaderNamesLive } from "@executor-js/sdk/http-auth"; + import { CountingSpanExporter, CountingSpanProcessor, @@ -129,6 +131,10 @@ export const flushTracerProvider = async (): Promise => { } }; +// The widened redacted-header list travels WITH the tracer, not just with the +// app layer: the MCP worker handler and the session Durable Object each run +// their programs under this layer directly (`Effect.runPromise` from the raw +// worker entry), and those fibers open client spans too. const makeTelemetryLive = (): Layer.Layer => Layer.unwrap( Effect.sync(() => @@ -137,8 +143,9 @@ const makeTelemetryLive = (): Layer.Layer => Layer.provide( Resource.layer({ serviceName: SERVICE_NAME, serviceVersion: SERVICE_VERSION }), ), + Layer.merge(RedactedHeaderNamesLive), ) - : Layer.empty, + : RedactedHeaderNamesLive, ), ); diff --git a/apps/host-selfhost/src/account/better-auth-account-provider.ts b/apps/host-selfhost/src/account/better-auth-account-provider.ts index 472bbf658..d7ff859ff 100644 --- a/apps/host-selfhost/src/account/better-auth-account-provider.ts +++ b/apps/host-selfhost/src/account/better-auth-account-provider.ts @@ -1,4 +1,4 @@ -import { Effect, Layer } from "effect"; +import { Effect, Layer, Redacted } from "effect"; import { AccountProvider, type AccountHeaders } from "@executor-js/api/server"; import { AccountError, AccountUnauthorized } from "@executor-js/api"; @@ -97,7 +97,10 @@ export const betterAuthAccountProvider: Layer.Layer"); + expect(created.obfuscatedValue, "the display value is masked, not the secret").not.toBe(secret); - // The created secret is a working credential for the protected API. + // The created secret is a working credential for the protected API — the + // proof the wrapping preserved the bytes end to end. const bearer = yield* Effect.promise(() => fetch(new URL("/api/integrations", target.baseUrl), { - headers: { authorization: `Bearer ${created.value}` }, + headers: { authorization: `Bearer ${secret}` }, }), ); expect(bearer.status, "the bearer authenticates the protected API").toBe(200); @@ -65,7 +71,7 @@ scenario( const mine = listed.apiKeys.find((key) => key.id === created.id); expect(mine?.name, "the created key appears in the list").toBe("e2e key"); expect(JSON.stringify(listed), "the list never leaks the plaintext secret").not.toContain( - created.value, + secret, ); // Revoke: the key disappears from the account. diff --git a/e2e/cloud/connection-owner-isolation.test.ts b/e2e/cloud/connection-owner-isolation.test.ts index e88c9dd2a..47d1c32a0 100644 --- a/e2e/cloud/connection-owner-isolation.test.ts +++ b/e2e/cloud/connection-owner-isolation.test.ts @@ -12,7 +12,7 @@ import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import type { HttpApiClient } from "effect/unstable/httpapi"; import { composePluginApi } from "@executor-js/api/server"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; @@ -173,7 +173,7 @@ scenario( name, integration, template: TEMPLATE_API_KEY, - value: secretValue, + value: Redacted.make(secretValue), }, }); @@ -230,7 +230,7 @@ scenario( name, integration, template: TEMPLATE_API_KEY, - value: "shared-org-key", + value: Redacted.make("shared-org-key"), }, }); @@ -269,7 +269,7 @@ scenario( name, integration, template: TEMPLATE_API_KEY, - value: "value-in-org-a", + value: Redacted.make("value-in-org-a"), }, }); diff --git a/e2e/cloud/connections-credentials.test.ts b/e2e/cloud/connections-credentials.test.ts index d94ce374a..9d4ed29db 100644 --- a/e2e/cloud/connections-credentials.test.ts +++ b/e2e/cloud/connections-credentials.test.ts @@ -8,7 +8,7 @@ import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import type { HttpApiClient } from "effect/unstable/httpapi"; import { composePluginApi } from "@executor-js/api/server"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; @@ -76,7 +76,7 @@ scenario( integration, template: TEMPLATE_API_KEY, identityLabel: "My API Token", - value: secretValue, + value: Redacted.make(secretValue), }, }); expect(created.name, "create returns the stored connection name").toBe(name); @@ -119,7 +119,7 @@ scenario( integration, template: TEMPLATE_API_KEY, identityLabel: "first key", - value: "first-value", + value: Redacted.make("first-value"), }, }); const first = yield* client.connections.list({ query: { integration } }); @@ -135,7 +135,7 @@ scenario( integration, template: TEMPLATE_API_KEY, identityLabel: "rotated key", - value: "second-value", + value: Redacted.make("second-value"), }, }); const second = yield* client.connections.list({ query: { integration } }); @@ -158,7 +158,13 @@ scenario( const name = freshConnectionName(); yield* client.connections.create({ - payload: { owner: "org", name, integration, template: TEMPLATE_API_KEY, value: "v" }, + payload: { + owner: "org", + name, + integration, + template: TEMPLATE_API_KEY, + value: Redacted.make("v"), + }, }); const removed = yield* client.connections.remove({ diff --git a/e2e/cloud/integrations-api.test.ts b/e2e/cloud/integrations-api.test.ts index 785fe3d09..49107c98c 100644 --- a/e2e/cloud/integrations-api.test.ts +++ b/e2e/cloud/integrations-api.test.ts @@ -14,7 +14,7 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { expect } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import { composePluginApi } from "@executor-js/api/server"; import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; import { @@ -162,7 +162,7 @@ scenario( name: MAIN, integration: slug, template: API_KEY, - value: "static-token", + value: Redacted.make("static-token"), }, }); @@ -271,7 +271,7 @@ scenario( name: MAIN, integration: slug, template: NONE, - value: "unused", + value: Redacted.make("unused"), }, }); @@ -328,7 +328,7 @@ scenario( name: MAIN, integration: slug, template: NONE, - value: "unused", + value: Redacted.make("unused"), }, }); @@ -390,7 +390,7 @@ scenario( name: MAIN, integration: slug, template: API_KEY, - value: "org-secret", + value: Redacted.make("org-secret"), }, }); yield* client.connections.create({ @@ -399,7 +399,7 @@ scenario( name: PERSONAL, integration: slug, template: API_KEY, - value: "personal-secret", + value: Redacted.make("personal-secret"), }, }); diff --git a/e2e/cloud/integrations-refresh.test.ts b/e2e/cloud/integrations-refresh.test.ts index fe2b70afd..18a65f73c 100644 --- a/e2e/cloud/integrations-refresh.test.ts +++ b/e2e/cloud/integrations-refresh.test.ts @@ -11,7 +11,7 @@ import { randomBytes } from "node:crypto"; import { createServer } from "node:http"; import { expect } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import { composePluginApi } from "@executor-js/api/server"; import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; @@ -103,7 +103,7 @@ scenario( name: MAIN, integration: slug, template: NONE, - value: "unused", + value: Redacted.make("unused"), }, }); diff --git a/e2e/cloud/spec-update-convergence.test.ts b/e2e/cloud/spec-update-convergence.test.ts index 2a20630df..fead697dd 100644 --- a/e2e/cloud/spec-update-convergence.test.ts +++ b/e2e/cloud/spec-update-convergence.test.ts @@ -12,7 +12,7 @@ import { randomBytes } from "node:crypto"; import { createServer } from "node:http"; import { expect } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import type { HttpApiClient } from "effect/unstable/httpapi"; import { composePluginApi } from "@executor-js/api/server"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; @@ -161,7 +161,7 @@ const personalConnection = (client: Client, integration: IntegrationSlug, name: name, integration, template: AuthTemplateSlug.make("apiKey"), - value: `tok-${randomBytes(8).toString("hex")}`, + value: Redacted.make(`tok-${randomBytes(8).toString("hex")}`), }, }); diff --git a/e2e/cloud/telemetry-contract.test.ts b/e2e/cloud/telemetry-contract.test.ts index 680c15b6d..ac7aa881b 100644 --- a/e2e/cloud/telemetry-contract.test.ts +++ b/e2e/cloud/telemetry-contract.test.ts @@ -18,7 +18,7 @@ import { randomBytes } from "node:crypto"; import { createServer } from "node:http"; import { expect } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import { composePluginApi } from "@executor-js/api/server"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; @@ -115,7 +115,7 @@ scenario( name: ConnectionName.make("main"), integration: slug, template: AuthTemplateSlug.make("apiKey"), - value: "telemetry-scenario-token", + value: Redacted.make("telemetry-scenario-token"), }, }); diff --git a/e2e/cloud/tenant-isolation.test.ts b/e2e/cloud/tenant-isolation.test.ts index dcd30dbf1..b4ff6184e 100644 --- a/e2e/cloud/tenant-isolation.test.ts +++ b/e2e/cloud/tenant-isolation.test.ts @@ -6,7 +6,7 @@ import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import type { HttpApiClient } from "effect/unstable/httpapi"; import { composePluginApi } from "@executor-js/api/server"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; @@ -71,7 +71,7 @@ scenario( name: connectionName, integration: slug, template: TEMPLATE_API_KEY, - value: secretValue, + value: Redacted.make(secretValue), }, }); diff --git a/e2e/cloud/toolkit-policy-perf.test.ts b/e2e/cloud/toolkit-policy-perf.test.ts index 05ef32a67..899b6f449 100644 --- a/e2e/cloud/toolkit-policy-perf.test.ts +++ b/e2e/cloud/toolkit-policy-perf.test.ts @@ -19,7 +19,7 @@ // production, a hard timeout); after it, it is sub-second. import { expect } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; import { scenario } from "../src/scenario"; @@ -110,7 +110,7 @@ scenario( name: ConnectionName.make("conn0"), integration: IntegrationSlug.make(controlIntegration), template: AuthTemplateSlug.make("apiKey"), - value: "unused-token", + value: Redacted.make("unused-token"), }, }); const toolkit = yield* controlClient.toolkits.create({ diff --git a/e2e/scenarios/auth-methods.test.ts b/e2e/scenarios/auth-methods.test.ts index 6e539a0eb..a669373ce 100644 --- a/e2e/scenarios/auth-methods.test.ts +++ b/e2e/scenarios/auth-methods.test.ts @@ -11,7 +11,7 @@ import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import { composePluginApi } from "@executor-js/api/server"; import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; import { makeEchoMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; @@ -330,7 +330,7 @@ scenario( name: ConnectionName.make("mixed"), integration: IntegrationSlug.make(slug), template: AuthTemplateSlug.make("token_and_team"), - values: { api_token: "tok_mixed", team_id: "team_42" }, + values: { api_token: Redacted.make("tok_mixed"), team_id: Redacted.make("team_42") }, }, }); yield* client.connections.create({ @@ -339,7 +339,7 @@ scenario( name: ConnectionName.make("bearer"), integration: IntegrationSlug.make(slug), template: AuthTemplateSlug.make("bearer"), - value: "tok_bearer", + value: Redacted.make("tok_bearer"), }, }); yield* client.connections.create({ @@ -348,7 +348,7 @@ scenario( name: ConnectionName.make("token"), integration: IntegrationSlug.make(slug), template: AuthTemplateSlug.make("query_token"), - value: "tok_query", + value: Redacted.make("tok_query"), }, }); diff --git a/e2e/scenarios/health-checks-mcp.test.ts b/e2e/scenarios/health-checks-mcp.test.ts index 562787b9b..d08375d94 100644 --- a/e2e/scenarios/health-checks-mcp.test.ts +++ b/e2e/scenarios/health-checks-mcp.test.ts @@ -17,7 +17,7 @@ // expiry correctly. import { randomBytes } from "node:crypto"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import { expect } from "@effect/vitest"; import type { HttpApiClient } from "effect/unstable/httpapi"; import { composePluginApi } from "@executor-js/api/server"; @@ -84,7 +84,7 @@ scenario( name, integration: slug, template: AuthTemplateSlug.make("bearer"), - value: goodToken, + value: Redacted.make(goodToken), }, }); @@ -101,7 +101,7 @@ scenario( owner: "org", integration: slug, template: AuthTemplateSlug.make("bearer"), - value: goodToken, + value: Redacted.make(goodToken), }, }); expect(validated.status, "validating a live key is healthy").toBe("healthy"); @@ -110,7 +110,7 @@ scenario( owner: "org", integration: slug, template: AuthTemplateSlug.make("bearer"), - value: "wrong-token", + value: Redacted.make("wrong-token"), }, }); expect(rejected.status, "validating a rejected key is expired").toBe("expired"); diff --git a/e2e/scenarios/health-checks-ui.test.ts b/e2e/scenarios/health-checks-ui.test.ts index 10ae01dca..2b990227d 100644 --- a/e2e/scenarios/health-checks-ui.test.ts +++ b/e2e/scenarios/health-checks-ui.test.ts @@ -21,7 +21,7 @@ import { randomBytes } from "node:crypto"; import { createServer } from "node:http"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import { expect } from "@effect/vitest"; import type { HttpApiClient } from "effect/unstable/httpapi"; import type { Page } from "playwright"; @@ -694,7 +694,7 @@ scenario( name, integration: slug, template: TEMPLATE, - value: goodToken, + value: Redacted.make(goodToken), }, }); @@ -862,7 +862,7 @@ scenario( name, integration: slug, template: TEMPLATE, - value: goodToken, + value: Redacted.make(goodToken), }, }); @@ -1118,7 +1118,7 @@ scenario( name, integration: slug, template: AuthTemplateSlug.make("bearer"), - value: "revoked-token", + value: Redacted.make("revoked-token"), }, }); diff --git a/e2e/scenarios/health-checks.test.ts b/e2e/scenarios/health-checks.test.ts index c4db028d4..49e5185eb 100644 --- a/e2e/scenarios/health-checks.test.ts +++ b/e2e/scenarios/health-checks.test.ts @@ -18,7 +18,7 @@ import { randomBytes } from "node:crypto"; import { createServer } from "node:http"; import { expect } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import type { HttpApiClient } from "effect/unstable/httpapi"; import { composePluginApi } from "@executor-js/api/server"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; @@ -262,7 +262,12 @@ scenario( // Key-first connect: a pasted key is probed WITHOUT saving, and the // probe surfaces the identity the UI fills the connection name from. const healthy = yield* client.connections.validate({ - payload: { owner: "org", integration: slug, template: TEMPLATE, value: goodToken }, + payload: { + owner: "org", + integration: slug, + template: TEMPLATE, + value: Redacted.make(goodToken), + }, }); expect(healthy.status, "a live key validates as healthy").toBe("healthy"); expect(healthy.httpStatus, "the probe saw the 200").toBe(200); @@ -270,7 +275,12 @@ scenario( // A revoked / wrong key validates as expired, with no identity. const expired = yield* client.connections.validate({ - payload: { owner: "org", integration: slug, template: TEMPLATE, value: "wrong-key" }, + payload: { + owner: "org", + integration: slug, + template: TEMPLATE, + value: Redacted.make("wrong-key"), + }, }); expect(expired.status, "a rejected key validates as expired").toBe("expired"); expect(expired.httpStatus, "the probe saw the 401").toBe(401); @@ -312,7 +322,7 @@ scenario( name, integration: slug, template: TEMPLATE, - value: goodToken, + value: Redacted.make(goodToken), }, }); const healthy = yield* client.connections.checkHealth({ @@ -331,7 +341,7 @@ scenario( name, integration: slug, template: TEMPLATE, - value: "rotated-away", + value: Redacted.make("rotated-away"), }, }); const expired = yield* client.connections.checkHealth({ @@ -444,7 +454,7 @@ scenario( name, integration: slug, template: TEMPLATE, - value: goodToken, + value: Redacted.make(goodToken), }, }); const result = yield* client.connections.checkHealth({ @@ -501,7 +511,7 @@ scenario( name, integration: slug, template: TEMPLATE, - value: goodToken, + value: Redacted.make(goodToken), }, }); @@ -584,7 +594,7 @@ scenario( name, integration: slug, template: TEMPLATE, - value: secret, + value: Redacted.make(secret), }, }); @@ -638,7 +648,7 @@ scenario( name, integration: slug, template: TEMPLATE, - value: goodToken, + value: Redacted.make(goodToken), }, }); @@ -655,7 +665,7 @@ scenario( name, integration: slug, template: TEMPLATE, - value: "rotated-away", + value: Redacted.make("rotated-away"), }, }); diff --git a/e2e/scenarios/mcp-catalog-sync.test.ts b/e2e/scenarios/mcp-catalog-sync.test.ts index bc9bc7023..75533c3be 100644 --- a/e2e/scenarios/mcp-catalog-sync.test.ts +++ b/e2e/scenarios/mcp-catalog-sync.test.ts @@ -23,7 +23,7 @@ import { randomBytes } from "node:crypto"; import { createServer } from "node:http"; import { expect } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import { composePluginApi } from "@executor-js/api/server"; import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; import { makeMutableCatalogMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; @@ -203,7 +203,7 @@ scenario( name: ConnectionName.make("main"), integration: IntegrationSlug.make(slug), template: AuthTemplateSlug.make("none"), - value: "", + value: Redacted.make(""), }, }); @@ -283,7 +283,7 @@ scenario( name: ConnectionName.make("main"), integration: IntegrationSlug.make(slug), template: AuthTemplateSlug.make("none"), - value: "", + value: Redacted.make(""), }, }); @@ -389,7 +389,7 @@ scenario( name: ConnectionName.make("main"), integration: IntegrationSlug.make(slug), template: AuthTemplateSlug.make("none"), - value: "", + value: Redacted.make(""), }, }); @@ -456,7 +456,7 @@ scenario( name: ConnectionName.make("main"), integration: IntegrationSlug.make(slug), template: AuthTemplateSlug.make("none"), - value: "", + value: Redacted.make(""), }, }); diff --git a/e2e/scenarios/mcp-session-state.test.ts b/e2e/scenarios/mcp-session-state.test.ts index 21dfa0f08..719bb09f8 100644 --- a/e2e/scenarios/mcp-session-state.test.ts +++ b/e2e/scenarios/mcp-session-state.test.ts @@ -9,7 +9,7 @@ import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; -import { Effect, Predicate } from "effect"; +import { Effect, Predicate, Redacted } from "effect"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { composePluginApi } from "@executor-js/api/server"; import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; @@ -104,7 +104,7 @@ scenario( name: ConnectionName.make("main"), integration: IntegrationSlug.make(slug), template: AuthTemplateSlug.make("none"), - value: "", + value: Redacted.make(""), }, }); diff --git a/e2e/scenarios/microsoft-graph-default.test.ts b/e2e/scenarios/microsoft-graph-default.test.ts index 88619e119..98b5ee840 100644 --- a/e2e/scenarios/microsoft-graph-default.test.ts +++ b/e2e/scenarios/microsoft-graph-default.test.ts @@ -1,7 +1,7 @@ import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import { composePluginApi } from "@executor-js/api/server"; import { MICROSOFT_AUTH_TEMPLATE_SLUG, @@ -129,7 +129,7 @@ scenario( name: connection, integration: IntegrationSlug.make(integration), template: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG), - value: "token-xyz", + value: Redacted.make("token-xyz"), }, }); diff --git a/e2e/scenarios/microsoft-graph-full.test.ts b/e2e/scenarios/microsoft-graph-full.test.ts index bba32a2c2..66e116ba9 100644 --- a/e2e/scenarios/microsoft-graph-full.test.ts +++ b/e2e/scenarios/microsoft-graph-full.test.ts @@ -1,7 +1,7 @@ import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import { composePluginApi } from "@executor-js/api/server"; import { MICROSOFT_AUTH_TEMPLATE_SLUG, @@ -90,7 +90,7 @@ scenario( name: connection, integration: IntegrationSlug.make(integration), template: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG), - value: "token-xyz", + value: Redacted.make("token-xyz"), }, }); diff --git a/e2e/scenarios/namespace-enumeration.test.ts b/e2e/scenarios/namespace-enumeration.test.ts index b4c068472..fd8d5c5c2 100644 --- a/e2e/scenarios/namespace-enumeration.test.ts +++ b/e2e/scenarios/namespace-enumeration.test.ts @@ -7,7 +7,7 @@ import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import { composePluginApi } from "@executor-js/api/server"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; @@ -83,7 +83,7 @@ scenario( name: ConnectionName.make("main"), integration: IntegrationSlug.make(slug), template: AuthTemplateSlug.make("apiKey"), - value: "tok_enum", + value: Redacted.make("tok_enum"), }, }); diff --git a/e2e/scenarios/openapi-unknown-args.test.ts b/e2e/scenarios/openapi-unknown-args.test.ts index faca4b460..63cf9738d 100644 --- a/e2e/scenarios/openapi-unknown-args.test.ts +++ b/e2e/scenarios/openapi-unknown-args.test.ts @@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto"; import { createServer } from "node:http"; import { expect } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import { composePluginApi } from "@executor-js/api/server"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; @@ -110,7 +110,7 @@ scenario( name: ConnectionName.make("main"), integration: IntegrationSlug.make(slug), template: AuthTemplateSlug.make("apiKey"), - value: "tok_unknown_args", + value: Redacted.make("tok_unknown_args"), }, }); diff --git a/e2e/scenarios/policies-ui.test.ts b/e2e/scenarios/policies-ui.test.ts index dec30dd4e..f3fd1e09e 100644 --- a/e2e/scenarios/policies-ui.test.ts +++ b/e2e/scenarios/policies-ui.test.ts @@ -20,7 +20,7 @@ import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import { composePluginApi } from "@executor-js/api/server"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; @@ -132,7 +132,7 @@ scenario( integration, template: TEMPLATE_API_KEY, identityLabel: `${name} key`, - value: `sk-${name}`, + value: Redacted.make(`sk-${name}`), }, }), ); diff --git a/e2e/scenarios/support/large-catalog.ts b/e2e/scenarios/support/large-catalog.ts index 7c21e6b33..c5a07a146 100644 --- a/e2e/scenarios/support/large-catalog.ts +++ b/e2e/scenarios/support/large-catalog.ts @@ -16,7 +16,7 @@ import { randomBytes } from "node:crypto"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import type { HttpApi, HttpApiClient } from "effect/unstable/httpapi"; import { composePluginApi } from "@executor-js/api/server"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; @@ -184,7 +184,7 @@ export const seedLargeCatalog = ( name: ConnectionName.make("conn0"), integration: IntegrationSlug.make(integration.slug), template: AuthTemplateSlug.make("apiKey"), - value: "unused-token", + value: Redacted.make("unused-token"), }, }); } diff --git a/e2e/scenarios/toolkits-mcp.test.ts b/e2e/scenarios/toolkits-mcp.test.ts index bd0b91229..509ab31c3 100644 --- a/e2e/scenarios/toolkits-mcp.test.ts +++ b/e2e/scenarios/toolkits-mcp.test.ts @@ -3,7 +3,7 @@ import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; import { expect } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import { composePluginApi } from "@executor-js/api/server"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; @@ -231,7 +231,7 @@ scenario( name: ConnectionName.make(name), integration: IntegrationSlug.make(integration), template: AuthTemplateSlug.make("apiKey"), - value: "unused-token", + value: Redacted.make("unused-token"), }, }); } @@ -241,7 +241,7 @@ scenario( name: ConnectionName.make(personalConnection), integration: IntegrationSlug.make(integration), template: AuthTemplateSlug.make("apiKey"), - value: "unused-token", + value: Redacted.make("unused-token"), }, }); @@ -420,7 +420,7 @@ scenario( name: ConnectionName.make(connection), integration: IntegrationSlug.make(integration), template: AuthTemplateSlug.make("apiKey"), - value: "unused-token", + value: Redacted.make("unused-token"), }, }); @@ -638,7 +638,7 @@ scenario( name: ConnectionName.make("main"), integration: IntegrationSlug.make(slug), template: AuthTemplateSlug.make("apiKey"), - value: "unused-token", + value: Redacted.make("unused-token"), }, }); } diff --git a/e2e/selfhost/mcp-multi-auth.test.ts b/e2e/selfhost/mcp-multi-auth.test.ts index ee4f492a1..d12b30894 100644 --- a/e2e/selfhost/mcp-multi-auth.test.ts +++ b/e2e/selfhost/mcp-multi-auth.test.ts @@ -9,7 +9,7 @@ import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import { composePluginApi } from "@executor-js/api/server"; import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; @@ -66,7 +66,7 @@ scenario( name: ConnectionName.make("wire-auth-key"), integration: IntegrationSlug.make(slug), template: AuthTemplateSlug.make("header"), - value: token, + value: Redacted.make(token), }, }); diff --git a/e2e/selfhost/toolkits-ui.test.ts b/e2e/selfhost/toolkits-ui.test.ts index aac0e96dd..3bcccfe4f 100644 --- a/e2e/selfhost/toolkits-ui.test.ts +++ b/e2e/selfhost/toolkits-ui.test.ts @@ -1,7 +1,7 @@ import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import { composePluginApi } from "@executor-js/api/server"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; @@ -100,7 +100,7 @@ scenario( name: ConnectionName.make(hiddenPersonalConnection), integration: IntegrationSlug.make(hiddenPersonalIntegration), template: AuthTemplateSlug.make("apiKey"), - value: "unused-token", + value: Redacted.make("unused-token"), }, }); diff --git a/examples/all-plugins/src/main.ts b/examples/all-plugins/src/main.ts index 9b81cd644..5b8d935b6 100644 --- a/examples/all-plugins/src/main.ts +++ b/examples/all-plugins/src/main.ts @@ -18,12 +18,13 @@ // default. // --------------------------------------------------------------------------- -import { Cause, Effect, Result } from "effect"; +import { Cause, Effect, Redacted, Result } from "effect"; import { AuthTemplateSlug, ConnectionName, createExecutor, + credentialValueToWrite, IntegrationSlug, ProviderItemId, ProviderKey, @@ -61,15 +62,20 @@ import { workosVaultPlugin } from "@executor-js/plugin-workos-vault"; // A connection's value lives in a writable credential provider. This tiny // in-memory store is enough for a script; the keychain / file-secrets / // 1Password plugins below contribute durable ones. Providers are Effect-native, -// so `get`/`set` return `Effect`s. +// so `get`/`set` return `Effect`s, and `get` hands back a `Redacted` so a +// credential cannot land in a log by accident. const memory = new Map(); const memoryProvider: CredentialProvider = { key: ProviderKey.make("memory"), writable: true, - get: (id: ProviderItemId) => Effect.sync(() => memory.get(String(id)) ?? null), - set: (id: ProviderItemId, value: string) => + get: (id: ProviderItemId) => Effect.sync(() => { - memory.set(String(id), value); + const value = memory.get(String(id)); + return value === undefined ? null : Redacted.make(value); + }), + set: (id: ProviderItemId, value: string | Redacted.Redacted) => + Effect.sync(() => { + memory.set(String(id), credentialValueToWrite(value)); }), }; diff --git a/examples/docs-sdk-quickstart/src/main.ts b/examples/docs-sdk-quickstart/src/main.ts index 77e60425e..d4d304ce1 100644 --- a/examples/docs-sdk-quickstart/src/main.ts +++ b/examples/docs-sdk-quickstart/src/main.ts @@ -1,8 +1,9 @@ // This example is the source of truth for docs snippets on /sdk/quickstart. // Run `bun run docs:snippets` after editing docs:start/docs:end blocks. -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import { createExecutor, + credentialValueToWrite, ProviderItemId, ProviderKey, type CredentialProvider, @@ -79,15 +80,20 @@ const inventoryApi = { // A connection stores its value in a writable credential provider. This tiny // in-memory store is enough for a script; production hosts swap in a durable // provider (keychain, 1Password, an encrypted DB store, …). Providers are -// Effect-native, so `get`/`set` return `Effect`s. +// Effect-native, so `get`/`set` return `Effect`s, and `get` hands back a +// `Redacted` so a credential cannot land in a log by accident. const memory = new Map(); const memoryProvider: CredentialProvider = { key: ProviderKey.make("memory"), writable: true, - get: (id: ProviderItemId) => Effect.sync(() => memory.get(String(id)) ?? null), - set: (id: ProviderItemId, value: string) => + get: (id: ProviderItemId) => Effect.sync(() => { - memory.set(String(id), value); + const value = memory.get(String(id)); + return value === undefined ? null : Redacted.make(value); + }), + set: (id: ProviderItemId, value: string | Redacted.Redacted) => + Effect.sync(() => { + memory.set(String(id), credentialValueToWrite(value)); }), }; diff --git a/examples/promise-sdk/src/main.ts b/examples/promise-sdk/src/main.ts index d310d55b0..13e618758 100644 --- a/examples/promise-sdk/src/main.ts +++ b/examples/promise-sdk/src/main.ts @@ -12,11 +12,12 @@ */ import { createExecutor, + credentialValueToWrite, ProviderItemId, ProviderKey, type CredentialProvider, } from "@executor-js/sdk/promise"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import { mcpPlugin } from "@executor-js/plugin-mcp/promise"; import { openApiPlugin, variable } from "@executor-js/plugin-openapi/promise"; import { graphqlPlugin } from "@executor-js/plugin-graphql/promise"; @@ -27,7 +28,8 @@ import { graphqlPlugin } from "@executor-js/plugin-graphql/promise"; // A connection stores its value in a writable credential provider. This tiny // in-memory store is enough for a script; production hosts swap in a durable // provider (keychain, 1Password, an encrypted DB store). Providers are -// Effect-native, so `get`/`set` return `Effect`s. +// Effect-native, so `get`/`set` return `Effect`s, and `get` hands back a +// `Redacted` so a credential cannot land in a log by accident. // --------------------------------------------------------------------------- const plugins = [mcpPlugin(), openApiPlugin(), graphqlPlugin()] as const; @@ -36,10 +38,14 @@ const memory = new Map(); const memoryProvider: CredentialProvider = { key: ProviderKey.make("memory"), writable: true, - get: (id: ProviderItemId) => Effect.sync(() => memory.get(String(id)) ?? null), - set: (id: ProviderItemId, value: string) => + get: (id: ProviderItemId) => Effect.sync(() => { - memory.set(String(id), value); + const value = memory.get(String(id)); + return value === undefined ? null : Redacted.make(value); + }), + set: (id: ProviderItemId, value: string | Redacted.Redacted) => + Effect.sync(() => { + memory.set(String(id), credentialValueToWrite(value)); }), }; diff --git a/packages/core/api/src/account/api.ts b/packages/core/api/src/account/api.ts index f3f3dfa20..40b590cde 100644 --- a/packages/core/api/src/account/api.ts +++ b/packages/core/api/src/account/api.ts @@ -1,5 +1,5 @@ import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; -import { Schema } from "effect"; +import { Redacted, Schema, SchemaGetter } from "effect"; // --------------------------------------------------------------------------- // Provider-neutral Account API. @@ -86,7 +86,21 @@ export const CreateApiKeyBody = Schema.Struct({ name: Schema.String, }); -/** Create returns the summary PLUS the one-time plaintext `value`. */ +/** Create returns the summary PLUS the one-time secret `value`, which is + * `Redacted` on both sides of the wire: the provider hands the handler a + * wrapped secret and this schema unwraps it into the response body, the client + * wraps it again as the body decodes. Only the transport carries it bare, and + * the UI's one-time display is the single deliberate unwrap. Hand-rolled + * rather than `Schema.Redacted`, whose encode is forbidden — this schema has + * to encode, since it is a RESPONSE. */ +const OneTimeSecret = Schema.String.pipe( + Schema.decodeTo(Schema.Redacted(Schema.String), { + decode: SchemaGetter.transform(Redacted.make), + // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the encode side of the wire codec; the transport is the only place it is bare + encode: SchemaGetter.transform(Redacted.value), + }), +); + export const CreatedApiKeyResponse = Schema.Struct({ id: Schema.String, name: Schema.String, @@ -94,7 +108,7 @@ export const CreatedApiKeyResponse = Schema.Struct({ createdAt: Schema.String, updatedAt: Schema.String, lastUsedAt: Schema.NullOr(Schema.String), - value: Schema.String, + value: OneTimeSecret, }); export const OrgMember = Schema.Struct({ diff --git a/packages/core/api/src/account/created-api-key.test.ts b/packages/core/api/src/account/created-api-key.test.ts new file mode 100644 index 000000000..6a93e5498 --- /dev/null +++ b/packages/core/api/src/account/created-api-key.test.ts @@ -0,0 +1,63 @@ +// --------------------------------------------------------------------------- +// The one-time API key crosses the account API as `Redacted` on both sides. +// +// This is a WRITE path in the sense that matters: the server ENCODES the +// secret into the response body. `Redacted`'s toString/toJSON render the +// literal "", so a regression to `Schema.String` on the provider side +// would not throw — it would ship that literal to the console, and the user +// would only find out when the key they copied failed to authenticate. So the +// assertion is on the encoded bytes, not just on the wrapper type. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Redacted, Schema } from "effect"; + +import { CreatedApiKeyResponse } from "./api"; + +// Synthetic, and asserted by exact match, so "" cannot pass for it. +const KEY_SECRET = "synthetic-one-time-key"; + +const summary = { + id: "api_key_123", + name: "Local CLI", + obfuscatedValue: "exk_...1234", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + lastUsedAt: null, +}; + +describe("CreatedApiKeyResponse", () => { + it.effect("encodes the real secret into the response body", () => + Effect.gen(function* () { + const encoded = yield* Schema.encodeUnknownEffect(CreatedApiKeyResponse)({ + ...summary, + value: Redacted.make(KEY_SECRET), + }); + + // The bytes on the wire are the key itself — the failure a missed unwrap + // on this path produces is the literal "" reaching the console. + expect(encoded.value).toBe(KEY_SECRET); + expect(encoded.obfuscatedValue).toBe(summary.obfuscatedValue); + }), + ); + + it.effect("decodes the body back into Redacted, so serializing it exposes nothing", () => + Effect.gen(function* () { + const decoded = yield* Schema.decodeUnknownEffect(CreatedApiKeyResponse)({ + ...summary, + value: KEY_SECRET, + }); + + expect(Redacted.isRedacted(decoded.value)).toBe(true); + + // What a log line, a span attribute, or an error payload would produce. + const serialized = JSON.stringify(decoded); + expect(serialized).not.toContain(KEY_SECRET); + expect(serialized).toContain(""); + + // …and the wrapper still holds the real key, so the check above is + // redaction rather than a dropped value. + expect(Redacted.value(decoded.value)).toBe(KEY_SECRET); + }), + ); +}); diff --git a/packages/core/api/src/connections/api.ts b/packages/core/api/src/connections/api.ts index c93e983cb..af2a35700 100644 --- a/packages/core/api/src/connections/api.ts +++ b/packages/core/api/src/connections/api.ts @@ -8,7 +8,7 @@ // --------------------------------------------------------------------------- import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; -import { Predicate, Schema } from "effect"; +import { Predicate, Redacted, Schema, SchemaGetter } from "effect"; import { AuthTemplateSlug, @@ -96,10 +96,22 @@ const UpdateConnectionPayload = Schema.Struct({ identityLabel: Schema.optional(Schema.NullOr(Schema.String)), }); -const CreateConnectionPayload = Schema.Struct({ +// The user-pasted credential, wrapped the instant it is decoded so nothing +// downstream of this line handles it bare. Hand-rolled rather than +// `Schema.Redacted`, whose encode is forbidden: this same schema encodes on the +// browser client that SENDS the credential, so the encode side is required. +const PastedSecret = Schema.String.pipe( + Schema.decodeTo(Schema.Redacted(Schema.String), { + decode: SchemaGetter.transform(Redacted.make), + // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the encode side of the wire codec; the transport is the only place it is bare + encode: SchemaGetter.transform(Redacted.value), + }), +); + +export const CreateConnectionPayload = Schema.Struct({ ...CommonCreateFields, - value: Schema.optional(Schema.String), - values: Schema.optional(Schema.Record(Schema.String, Schema.String)), + value: Schema.optional(PastedSecret), + values: Schema.optional(Schema.Record(Schema.String, PastedSecret)), from: Schema.optional( Schema.Struct({ provider: ProviderKey, @@ -123,8 +135,8 @@ const ValidateConnectionPayload = Schema.Struct({ integration: IntegrationSlug, template: AuthTemplateSlug, spec: Schema.optional(HealthCheckSpec), - value: Schema.optional(Schema.String), - values: Schema.optional(Schema.Record(Schema.String, Schema.String)), + value: Schema.optional(PastedSecret), + values: Schema.optional(Schema.Record(Schema.String, PastedSecret)), from: Schema.optional( Schema.Struct({ provider: ProviderKey, diff --git a/packages/core/api/src/connections/redacted-payload.test.ts b/packages/core/api/src/connections/redacted-payload.test.ts new file mode 100644 index 000000000..080f8f6db --- /dev/null +++ b/packages/core/api/src/connections/redacted-payload.test.ts @@ -0,0 +1,213 @@ +// --------------------------------------------------------------------------- +// A user-pasted credential is `Redacted` from the moment the HTTP payload +// decodes, and the real bytes still reach the credential provider. +// +// Both halves are needed, and each is worthless alone. A payload that decodes +// into `Redacted` but drops the secret on the way to the provider looks safe +// and stores nothing usable; `Redacted`'s toString/toJSON render the literal +// "", so a missed unwrap on this WRITE path does not throw — it +// persists that literal and the connection fails later, far from the cause. +// So: assert the stored bytes ARE the secret, and that serializing the decoded +// payload exposes none of it. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Redacted, Schema } from "effect"; +import { HttpApiBuilder, HttpApiClient } from "effect/unstable/httpapi"; +import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; +import { Context, Layer } from "effect"; + +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + ProviderItemId, + ProviderKey, + ToolName, + createExecutor, + definePlugin, + credentialValueToWrite, + type CredentialProvider, + type Executor, +} from "@executor-js/sdk"; +import { makeTestConfig } from "@executor-js/sdk/testing"; + +import { ExecutorApi } from "../api"; +import { CreateConnectionPayload } from "./api"; +import { observabilityMiddleware } from "../observability"; +import { CoreHandlers, ExecutionEngineService, ExecutorService } from "../server"; + +// Synthetic, and asserted by exact match, so a "" rendering cannot +// pass for the real thing. +const TOKEN_SECRET = "synthetic-pasted-token"; +const TEAM_SECRET = "synthetic-pasted-team"; + +const BASE_URL = "http://localhost"; +const INTEGRATION = IntegrationSlug.make("acme"); +const TEMPLATE = AuthTemplateSlug.make("apiKey"); + +// A writable provider that records exactly what bytes it was handed, so the +// assertion is on what would be persisted rather than on what reads back. +const makeRecordingCredentialsPlugin = () => { + const store = new Map(); + const provider: CredentialProvider = { + key: ProviderKey.make("recording"), + writable: true, + get: (id) => + Effect.sync(() => { + const value = store.get(String(id)); + return value === undefined ? null : Redacted.make(value); + }), + set: (id, value) => + Effect.sync(() => { + store.set(String(id), credentialValueToWrite(value)); + }), + list: () => + Effect.sync(() => + Array.from(store.keys()).map((key) => ({ id: ProviderItemId.make(key), name: key })), + ), + }; + const plugin = definePlugin(() => ({ + id: "recording-credentials" as const, + storage: () => ({}), + credentialProviders: [provider], + }))(); + return { plugin, storedValues: () => [...store.values()] }; +}; + +const acmePlugin = definePlugin(() => ({ + id: "acme" as const, + storage: () => ({}), + resolveTools: () => + Effect.succeed({ tools: [{ name: ToolName.make("ping"), description: "ping" }] }), + invokeTool: () => Effect.succeed({ ok: true }), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ slug: INTEGRATION, description: "Acme", config: {} }), + }), +}))(); + +const webHandlerFor = (executor: Executor) => + Effect.acquireRelease( + Effect.sync(() => + HttpRouter.toWebHandler( + HttpApiBuilder.layer(ExecutorApi).pipe( + Layer.provide(CoreHandlers), + Layer.provide(observabilityMiddleware(ExecutorApi)), + Layer.provide(Layer.succeed(ExecutorService)(executor)), + Layer.provide( + Layer.succeed(ExecutionEngineService)({} as ExecutionEngineService["Service"]), + ), + Layer.provideMerge(HttpServer.layerServices), + Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })), + ), + { disableLogger: true }, + ), + ), + (web) => Effect.promise(() => web.dispose()), + ); + +const handlerContextFor = (executor: Executor) => + Context.make(ExecutorService, executor).pipe( + Context.add(ExecutionEngineService, {} as ExecutionEngineService["Service"]), + ); + +describe("pasted connection credentials over HTTP", () => { + it.effect("stores the real bytes a create payload carried", () => + Effect.gen(function* () { + const recording = makeRecordingCredentialsPlugin(); + const executor = yield* createExecutor( + makeTestConfig({ plugins: [recording.plugin, acmePlugin] as const }), + ); + yield* executor.acme.seed(); + const web = yield* webHandlerFor(executor); + const context = handlerContextFor(executor); + + const response = yield* Effect.promise(() => + web.handler( + new Request("http://localhost/connections", { + method: "POST", + headers: { "content-type": "application/json" }, + body: `{"owner":"org","name":"main","integration":"acme","template":"apiKey","values":{"token":"${TOKEN_SECRET}","team":"${TEAM_SECRET}"}}`, + }), + context, + ), + ); + expect(response.status).toBe(200); + + // The bytes the provider was handed are the secret, not "" — + // the failure a missed unwrap on this path produces. + const stored = recording.storedValues().sort(); + expect(stored).toEqual([TEAM_SECRET, TOKEN_SECRET].sort()); + }), + ); + + it.effect("decodes a pasted secret into Redacted, so serializing it exposes nothing", () => + Effect.gen(function* () { + const decoded = yield* Schema.decodeUnknownEffect(CreateConnectionPayload)({ + owner: "org", + name: "main", + integration: "acme", + template: "apiKey", + values: { token: TOKEN_SECRET, team: TEAM_SECRET }, + }); + + // What a log line, a span attribute, or an error payload would produce. + const serialized = JSON.stringify(decoded); + expect(serialized).not.toContain(TOKEN_SECRET); + expect(serialized).not.toContain(TEAM_SECRET); + expect(serialized).toContain(""); + + // …and the wrappers still hold the real secrets, so the assertions above + // are redaction rather than an empty payload. + const values = decoded.values ?? {}; + expect(Object.values(values).every(Redacted.isRedacted)).toBe(true); + expect(Redacted.value(values["token"]!)).toBe(TOKEN_SECRET); + expect(Redacted.value(values["team"]!)).toBe(TEAM_SECRET); + }), + ); + + it.effect("sends a wrapped secret through the generated client, as packages/react does", () => + Effect.gen(function* () { + // The browser client ENCODES through this same schema. `Schema.Redacted` + // forbids encoding, so a regression to it would break the send path at + // runtime, not at the type level — hence a real client, not a bare + // `Schema.encode` call. + const recording = makeRecordingCredentialsPlugin(); + const executor = yield* createExecutor( + makeTestConfig({ plugins: [recording.plugin, acmePlugin] as const }), + ); + yield* executor.acme.seed(); + const web = yield* webHandlerFor(executor); + const context = handlerContextFor(executor); + + // The client hands `fetch` an (input, init) pair; the web handler wants a + // whole `Request`, so normalize rather than assume one shape. + const fetchIntoHandler = ((input: RequestInfo | URL, init?: RequestInit) => + web.handler(new Request(input as RequestInfo, init), context)) as typeof globalThis.fetch; + + const client = yield* HttpApiClient.make(ExecutorApi, { baseUrl: BASE_URL }).pipe( + Effect.provide( + FetchHttpClient.layer.pipe( + Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(fetchIntoHandler)), + ), + ), + ); + + const created = yield* client.connections.create({ + payload: { + owner: "org", + name: ConnectionName.make("main"), + integration: INTEGRATION, + template: TEMPLATE, + value: Redacted.make(TOKEN_SECRET), + }, + }); + expect(created.provider).toBe(ProviderKey.make("recording")); + + // Round-tripped through a real request: encoded to the body, decoded by + // the server, and written to the provider as the original bytes. + expect(recording.storedValues()).toEqual([TOKEN_SECRET]); + }), + ); +}); diff --git a/packages/core/api/src/handlers/oauth.ts b/packages/core/api/src/handlers/oauth.ts index 5187908c9..d55118648 100644 --- a/packages/core/api/src/handlers/oauth.ts +++ b/packages/core/api/src/handlers/oauth.ts @@ -18,6 +18,7 @@ import { OAuthSessionNotFoundError, OAuthStartError, OAuthState, + oauthClientSecretFromInput, type Connection, type ConnectResult, } from "@executor-js/sdk"; @@ -102,7 +103,7 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler tokenUrl: payload.tokenUrl, grant: payload.grant, clientId: payload.clientId, - clientSecret: payload.clientSecret, + clientSecret: oauthClientSecretFromInput(payload.clientSecret), resource: payload.resource ?? null, origin: { kind: "manual", integration: payload.originIntegration ?? null }, }); diff --git a/packages/core/api/src/oauth/api.ts b/packages/core/api/src/oauth/api.ts index 4bf74786a..c1ae08d5c 100644 --- a/packages/core/api/src/oauth/api.ts +++ b/packages/core/api/src/oauth/api.ts @@ -64,6 +64,9 @@ const CreateClientPayload = Schema.Struct({ tokenUrl: Schema.String, grant: Schema.Literals(["authorization_code", "client_credentials"]), clientId: Schema.String, + /** The web form's secret field, where an empty string is the only spelling of + * "no secret". The handler normalizes it to the domain's null-or-value + * presence model before it reaches the service. */ clientSecret: Schema.String, resource: Schema.optional(Schema.NullOr(Schema.String)), /** Integration whose connect dialog registered this manual app. Recorded so diff --git a/packages/core/api/src/server/executor-app.ts b/packages/core/api/src/server/executor-app.ts index 96b8c1f7c..8c820adf6 100644 --- a/packages/core/api/src/server/executor-app.ts +++ b/packages/core/api/src/server/executor-app.ts @@ -43,6 +43,7 @@ import { HttpRouter } from "effect/unstable/http"; import { Effect, Layer } from "effect"; import type { AnyPlugin } from "@executor-js/sdk"; +import { RedactedHeaderNamesLive } from "@executor-js/sdk/http-auth"; import type { DbProvider } from "./executor-fuma-db"; import { HostConfig } from "./scoped-executor"; import type { PluginsProvider } from "./scoped-executor"; @@ -584,7 +585,16 @@ export const make = < // socket ONCE at boot. It is folded into the execution-stack middleware (above) // and into the account middleware + extension routes (the host self-combines // those) so each rebuilds per request. `boot` is the long-lived context. - const appLayer: AppRouteLayer = merged.pipe(Layer.provideMerge(options.boot)); + // + // `RedactedHeaderNamesLive` rides on `boot` so it reaches every fiber this + // host serves: `HttpMiddleware.tracer` reads `Headers.CurrentRedactedNames` + // for the inbound span, and every outbound `HttpClient` built under the same + // context reads it for the client span. Effect's default list stops at + // authorization/cookie/set-cookie/x-api-key, which is narrower than the + // header names an integration's auth placement can mint. + const appLayer: AppRouteLayer = merged.pipe( + Layer.provideMerge(Layer.merge(options.boot, RedactedHeaderNamesLive)), + ); return { api: protectedApi.api, diff --git a/packages/core/execution/src/description.test.ts b/packages/core/execution/src/description.test.ts index c95573fc1..57b176360 100644 --- a/packages/core/execution/src/description.test.ts +++ b/packages/core/execution/src/description.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import { AuthTemplateSlug, @@ -8,6 +8,7 @@ import { ProviderItemId, ProviderKey, createExecutor, + credentialValueToWrite, definePlugin, type CredentialProvider, } from "@executor-js/sdk"; @@ -20,8 +21,13 @@ const memoryProvider = (): CredentialProvider => { return { key: ProviderKey.make("memory"), writable: true, - get: (id) => Effect.sync(() => store.get(String(id)) ?? null), - set: (id, value) => Effect.sync(() => void store.set(String(id), value)), + get: (id) => + Effect.sync(() => { + const value = store.get(String(id)); + return value === undefined ? null : Redacted.make(value); + }), + set: (id, value) => + Effect.sync(() => void store.set(String(id), credentialValueToWrite(value))), has: (id) => Effect.sync(() => store.has(String(id))), list: () => Effect.sync(() => diff --git a/packages/core/execution/src/tool-invoker.test.ts b/packages/core/execution/src/tool-invoker.test.ts index dd25e6168..69906d9b8 100644 --- a/packages/core/execution/src/tool-invoker.test.ts +++ b/packages/core/execution/src/tool-invoker.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Data, Effect, Fiber, Schema } from "effect"; +import { Data, Effect, Fiber, Redacted, Schema } from "effect"; import { AuthTemplateSlug, @@ -14,6 +14,7 @@ import { ToolName, ToolResult, createExecutor, + credentialValueToWrite, definePlugin, type AnyPlugin, type CredentialProvider, @@ -134,8 +135,13 @@ const memoryProvider = (key: string): CredentialProvider => { return { key: ProviderKey.make(key), writable: true, - get: (id) => Effect.sync(() => store.get(String(id)) ?? null), - set: (id, value) => Effect.sync(() => void store.set(String(id), value)), + get: (id) => + Effect.sync(() => { + const value = store.get(String(id)); + return value === undefined ? null : Redacted.make(value); + }), + set: (id, value) => + Effect.sync(() => void store.set(String(id), credentialValueToWrite(value))), has: (id) => Effect.sync(() => store.has(String(id))), list: () => Effect.sync(() => @@ -340,7 +346,10 @@ const oauthErrorPlugin = definePlugin(() => ({ }, ], }), - invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }), + invokeTool: ({ credential }) => + Effect.succeed({ + token: credential.value === null ? null : Redacted.value(credential.value), + }), extension: (ctx) => ({ seed: () => ctx.core.integrations.register({ diff --git a/packages/core/sdk/src/connection.ts b/packages/core/sdk/src/connection.ts index 900901177..f3fde9576 100644 --- a/packages/core/sdk/src/connection.ts +++ b/packages/core/sdk/src/connection.ts @@ -1,3 +1,5 @@ +import type { Redacted } from "effect"; + import type { AuthTemplateSlug, ConnectionAddress, @@ -70,13 +72,21 @@ export interface ConnectionRef { readonly integration: IntegrationSlug; } +/** A pasted credential as it enters the SDK. `Redacted` is what the HTTP layer + * produces (the payload schema decodes straight into it) and what a caller + * holding an already-wrapped secret passes; a bare string stays accepted so the + * documented plain-string calls keep working. Widening, never narrowing — the + * guarantee lives on the OUTPUT side, where `CredentialProvider.get` returns + * `Redacted`. */ +export type ConnectionSecretInput = string | Redacted.Redacted; + /** Where a single credential input comes from. `value` is pasted raw and written * to the default provider; `from` references an external provider (1Password, * keychain) by opaque id — we store the routing and resolve on demand, never * holding the value. Applied to a template lazily, never pre-baked into * `Bearer …`. */ export type ConnectionInputOrigin = - | { readonly value: string } + | { readonly value: ConnectionSecretInput } | { readonly from: { readonly provider: ProviderKey; readonly id: ProviderItemId } }; /** The value origin(s) for a new credential. A connection resolves a MAP of named @@ -86,9 +96,9 @@ export type ConnectionInputOrigin = * is pasted multi-input; `inputs` is the canonical per-variable origin map (mixes * pasted + external). All inputs of one connection share one provider. */ export type ConnectionValueInput = - | { readonly value: string } + | { readonly value: ConnectionSecretInput } | { readonly from: { readonly provider: ProviderKey; readonly id: ProviderItemId } } - | { readonly values: Record } + | { readonly values: Record } | { readonly inputs: Record }; /** Save a credential for one integration (born wired). `template` picks which of diff --git a/packages/core/sdk/src/connections.test.ts b/packages/core/sdk/src/connections.test.ts index 7ed046f65..36554ebc6 100644 --- a/packages/core/sdk/src/connections.test.ts +++ b/packages/core/sdk/src/connections.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Predicate, Result } from "effect"; +import { Effect, Predicate, Redacted, Result } from "effect"; import { AuthTemplateSlug, @@ -12,7 +12,7 @@ import { } from "./ids"; import { createExecutor } from "./executor"; import { definePlugin } from "./plugin"; -import type { CredentialProvider } from "./provider"; +import { credentialValueToWrite, type CredentialProvider } from "./provider"; import { makeTestConfig, makeTestExecutor } from "./testing"; // removed: v1 connection-refresh lifecycle, ConnectionProvider.refresh, @@ -27,8 +27,13 @@ const memoryProvider = (): CredentialProvider => { return { key: ProviderKey.make("memory"), writable: true, - get: (id) => Effect.sync(() => store.get(String(id)) ?? null), - set: (id, value) => Effect.sync(() => void store.set(String(id), value)), + get: (id) => + Effect.sync(() => { + const value = store.get(String(id)); + return value === undefined ? null : Redacted.make(value); + }), + set: (id, value) => + Effect.sync(() => void store.set(String(id), credentialValueToWrite(value))), has: (id) => Effect.sync(() => store.has(String(id))), list: () => Effect.sync(() => @@ -54,8 +59,13 @@ const demoPlugin = definePlugin(() => ({ { name: ToolName.make("list"), description: "list" }, ], }), + // A tool result is a wire payload, so the plugin unwraps here — the same + // move a real plugin makes when it renders the credential onto a request. invokeTool: ({ toolRow, credential }) => - Effect.succeed({ ran: toolRow.name, value: credential.value }), + Effect.succeed({ + ran: toolRow.name, + value: credential.value === null ? null : Redacted.value(credential.value), + }), extension: (ctx) => ({ seed: () => ctx.core.integrations.register({ @@ -94,9 +104,10 @@ describe("connections.create", () => { const tools = yield* executor.tools.list(); expect(tools.map((t) => String(t.name)).sort()).toEqual(["deploy", "list"]); - // The inline value is resolvable via the connection's provider. + // The inline value is resolvable via the connection's provider, and + // arrives wrapped — the plugin contract hands out `Redacted`. const value = yield* executor.demo.resolveValue("org", "main"); - expect(value).toBe("secret-token"); + expect(value === null ? null : Redacted.value(value)).toBe("secret-token"); }), ); @@ -121,7 +132,7 @@ describe("connections.create", () => { ]); const value = yield* executor.demo.resolveValue("org", "myApiKey"); - expect(value).toBe("secret-token"); + expect(value === null ? null : Redacted.value(value)).toBe("secret-token"); }), ); diff --git a/packages/core/sdk/src/core-tools.ts b/packages/core/sdk/src/core-tools.ts index 085164e8b..e1f46a3ff 100644 --- a/packages/core/sdk/src/core-tools.ts +++ b/packages/core/sdk/src/core-tools.ts @@ -694,7 +694,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { // This persists an OAuth client and REPLACES on slug collision. It // takes NO client secret: a secret would have to travel through the // agent's context window, so a confidential app is registered by the - // human via `oauth.clients.createHandoff`. An empty secret registers a + // human via `oauth.clients.createHandoff`. An absent secret registers a // PUBLIC client. The remaining risk is the write itself: prompt-injected // code could register a client with an attacker-controlled // authorizationUrl/tokenUrl, then drive `oauth.start` to mint a @@ -711,10 +711,10 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { tokenUrl: input.tokenUrl, grant: input.grant, clientId: input.clientId, - // No secret crosses the agent boundary; an empty secret registers + // No secret crosses the agent boundary; a null secret registers // a public client. Confidential clients go through // `oauth.clients.createHandoff`. - clientSecret: "", + clientSecret: null, resource: input.resource ?? null, origin: { kind: "manual", diff --git a/packages/core/sdk/src/credential-scrub.test.ts b/packages/core/sdk/src/credential-scrub.test.ts new file mode 100644 index 000000000..e51ca402d --- /dev/null +++ b/packages/core/sdk/src/credential-scrub.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Data, Option, Redacted } from "effect"; + +import { makeCredentialScrubber } from "./credential-scrub"; + +// Synthetic values — never shaped like a real provider's credential. +const TOKEN = "synthetic-token-value"; +const TEAM = "synthetic-team-value"; + +/** Stands in for the plugin failure the openapi timeout paths hand to + * `scrub.payload` (`OpenApiInvocationError`): a tagged error whose message is + * the only diagnostic and whose `message`/`stack` are non-enumerable. */ +class UpstreamTimeout extends Data.TaggedError("UpstreamTimeout")<{ + readonly message: string; + readonly statusCode: Option.Option; + readonly reason: string; +}> {} + +describe("makeCredentialScrubber", () => { + it("removes every occurrence of every resolved value from text", () => { + const scrub = makeCredentialScrubber({ + token: Redacted.make(TOKEN), + team: Redacted.make(TEAM), + }); + const echoed = `GET /v1/thing?team=${TEAM} failed: Authorization: Bearer ${TOKEN} (token ${TOKEN})`; + + const out = scrub.text(echoed); + + expect(out).not.toContain(TOKEN); + expect(out).not.toContain(TEAM); + expect(out).toBe( + "GET /v1/thing?team=[redacted] failed: Authorization: Bearer [redacted] (token [redacted])", + ); + }); + + it("walks nested payloads, leaving non-string leaves intact", () => { + const scrub = makeCredentialScrubber({ token: Redacted.make(TOKEN) }); + + expect( + scrub.payload({ + status: 401, + ok: false, + errors: [{ detail: `bad token ${TOKEN}` }], + request: { headers: { authorization: `Bearer ${TOKEN}` } }, + }), + ).toEqual({ + status: 401, + ok: false, + errors: [{ detail: "bad token [redacted]" }], + request: { headers: { authorization: "Bearer [redacted]" } }, + }); + }); + + it('skips null entries and empty values — splitting on "" would shred the text', () => { + const scrub = makeCredentialScrubber({ + token: null, + blank: Redacted.make(""), + }); + + expect(scrub.text("nothing to remove here")).toBe("nothing to remove here"); + }); + + it("replaces a longer secret whole when a shorter one is its prefix", () => { + // Ordering matters: shortest-first would replace the prefix and strand the + // remainder of the longer secret in the output. + const short = "synthetic-abc"; + const long = "synthetic-abcdef"; + const scrub = makeCredentialScrubber({ + short: Redacted.make(short), + long: Redacted.make(long), + }); + + const out = scrub.text(`value=${long}`); + + expect(out).toBe("value=[redacted]"); + expect(out).not.toContain("def"); + }); + + it("keeps a tagged error's message and tag, with the credential scrubbed out", () => { + // `message`, `name`, and `stack` are non-enumerable on an Error, so a plain + // enumerable projection drops the ONLY diagnostic a timeout failure has. + const scrub = makeCredentialScrubber({ token: Redacted.make(TOKEN) }); + const failure = new UpstreamTimeout({ + message: `Upstream returned no response headers within 100ms for https://api.test/v1?token=${TOKEN}`, + statusCode: Option.none(), + reason: "response_headers_timeout", + }); + + const scrubbed = scrub.payload(failure); + + expect(scrubbed).toMatchObject({ + message: + "Upstream returned no response headers within 100ms for https://api.test/v1?token=[redacted]", + name: "UpstreamTimeout", + _tag: "UpstreamTimeout", + reason: "response_headers_timeout", + stack: expect.any(String), + }); + expect(JSON.stringify(scrubbed)).not.toContain(TOKEN); + }); + + it("marks a cycle instead of recursing into it", () => { + // A decoded response body is a tree, but a thrown failure is not: an error + // whose cause points back at it would recurse until the stack goes. + const scrub = makeCredentialScrubber({ token: Redacted.make(TOKEN) }); + const envelope: Record = { detail: `rejected ${TOKEN}` }; + envelope.self = envelope; + envelope.nested = [envelope]; + + expect(scrub.payload(envelope)).toEqual({ + detail: "rejected [redacted]", + self: "[circular]", + nested: ["[circular]"], + }); + }); +}); diff --git a/packages/core/sdk/src/credential-scrub.ts b/packages/core/sdk/src/credential-scrub.ts new file mode 100644 index 000000000..5d34ff193 --- /dev/null +++ b/packages/core/sdk/src/credential-scrub.ts @@ -0,0 +1,93 @@ +import { Predicate, Redacted } from "effect"; + +// --------------------------------------------------------------------------- +// Scrubbing resolved credential values out of text that leaves the process. +// +// Upstream error bodies and transport failure messages routinely echo the +// request back — a URL with its query string, a rejected `Authorization` +// header, a whole curl-shaped repro. Anything derived from such text and then +// surfaced (a health-check `detail`, a tool failure's `details` payload) can +// carry the credential the request authenticated with. +// +// This is the one place a resolved value is unwrapped for a READ whose purpose +// is to REMOVE it: the scrubber needs the plaintext to find it. The plaintext +// never leaves this module — only the scrubbed text does. +// --------------------------------------------------------------------------- + +/** The marker substituted for a credential occurrence. Deliberately not + * `Redacted`'s own "" rendering, so a scrubbed upstream body is never + * mistaken for a value that was serialized while still wrapped. */ +const SCRUB_MARKER = "[redacted]"; + +/** Substituted for a value already being walked. A decoded response body is a + * tree, but a thrown failure is not: an error's `cause` chain can point back at + * itself, and walking it would recurse until the stack goes. */ +const CIRCULAR_MARKER = "[circular]"; + +/** Removes every occurrence of a connection's resolved credential values from + * text. Total: text carrying no credential comes back unchanged. */ +export interface CredentialScrubber { + readonly text: (text: string) => string; + /** Scrub an arbitrary decoded upstream payload (an error body, a parsed JSON + * envelope, a thrown failure) before it is attached to a failure. Strings, + * arrays, and plain objects are walked; any other leaf is returned as-is, + * since these paths carry decoded response bodies, whose leaves are JSON + * scalars. */ + readonly payload: (payload: unknown) => unknown; +} + +/** Build a scrubber from a connection's resolved credential values. + * + * Empty values are skipped — splitting on "" would shred the text. Secrets are + * applied longest-first so a value containing another (an access token + * embedded in a composite header value) is replaced whole instead of leaving a + * fragment of the longer secret behind. */ +export const makeCredentialScrubber = ( + values: Record | null>, +): CredentialScrubber => { + const secrets = Object.values(values) + .filter(Predicate.isNotNull) + // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the plaintext is the needle to strip out; it is only ever compared, never emitted + .map(Redacted.value) + .filter((secret) => secret.length > 0) + .sort((a, b) => b.length - a.length); + + const text = (input: string): string => + secrets.reduce((out, secret) => out.split(secret).join(SCRUB_MARKER), input); + + const walk = (input: unknown, seen: ReadonlySet): unknown => { + if (typeof input === "string") return text(input); + if (Array.isArray(input)) { + if (seen.has(input)) return CIRCULAR_MARKER; + const next = new Set([...seen, input]); + return input.map((entry) => walk(entry, next)); + } + if (!Predicate.isObject(input)) return input; + if (seen.has(input)) return CIRCULAR_MARKER; + const next = new Set([...seen, input]); + const projected = Object.fromEntries( + Object.entries(input).map(([key, value]) => [key, walk(value, next)]), + ); + // oxlint-disable-next-line executor/no-instanceof-error -- boundary: `payload` takes the untyped value a failure carried; narrowing it is how the diagnostics below are recovered + if (!(input instanceof Error)) return projected; + // `name`, `message`, and `stack` are non-enumerable on an Error, so the + // projection above drops them — and the message is exactly where the + // credential lands, since a transport failure quotes the request it failed + // on. This path receives the raw error a plugin's invocation threw + // (`OpenApiInvocationError` on either timeout), whose message is the only + // diagnostic it carries; the same explicit projection `redactErrorCause` + // makes in `oauth-helpers.ts`, with the value-based scrub applied instead + // of that file's key-name policy. + return { + ...projected, + name: text(input.name), + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: narrowed to Error above; the message is scrubbed, not interpreted + message: text(input.message), + ...(input.stack === undefined ? {} : { stack: text(input.stack) }), + }; + }; + + const payload = (input: unknown): unknown => walk(input, new Set()); + + return { text, payload }; +}; diff --git a/packages/core/sdk/src/executor.test.ts b/packages/core/sdk/src/executor.test.ts index a7ec1272d..5a683f7ac 100644 --- a/packages/core/sdk/src/executor.test.ts +++ b/packages/core/sdk/src/executor.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Data, Effect, Predicate, Result } from "effect"; +import { Data, Effect, Predicate, Redacted, Result } from "effect"; import { ToolNotFoundError } from "./errors"; import { @@ -13,7 +13,7 @@ import { ToolName, } from "./ids"; import { definePlugin } from "./plugin"; -import type { CredentialProvider } from "./provider"; +import { credentialValueToWrite, type CredentialProvider } from "./provider"; import { IntegrationDetectionResult } from "./types"; import { makeTestExecutor } from "./testing"; import { serveOAuthTestServer } from "./testing/oauth-test-server"; @@ -32,8 +32,13 @@ const memoryProvider = (): CredentialProvider => { return { key: ProviderKey.make("memory"), writable: true, - get: (id) => Effect.sync(() => store.get(String(id)) ?? null), - set: (id, value) => Effect.sync(() => void store.set(String(id), value)), + get: (id) => + Effect.sync(() => { + const value = store.get(String(id)); + return value === undefined ? null : Redacted.make(value); + }), + set: (id, value) => + Effect.sync(() => void store.set(String(id), credentialValueToWrite(value))), }; }; diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 42dee8cd1..14f374502 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -1,4 +1,4 @@ -import { Effect, Inspectable, Layer, Option, Predicate, Schema } from "effect"; +import { Effect, Inspectable, Layer, Option, Predicate, Redacted, Schema } from "effect"; import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; import { fumadb } from "@executor-js/fumadb"; import { memoryAdapter } from "@executor-js/fumadb/adapters/memory"; @@ -92,7 +92,7 @@ import { type MintOAuthConnectionInput, type OAuthScopePolicy, } from "./oauth-service"; -import type { OAuthService } from "./oauth-client"; +import { oauthClientSecretFromInput, type OAuthService } from "./oauth-client"; import { comparePolicyRow, isValidPattern, @@ -1486,7 +1486,7 @@ export const createExecutor = + Effect.Effect | null, StorageFailure | CredentialResolutionError> >(); const connectionKey = (row: ConnectionRow): string => @@ -1504,7 +1504,10 @@ export const createExecutor = => + ): Effect.Effect< + Redacted.Redacted | null, + StorageFailure | CredentialResolutionError + > => Effect.gen(function* () { const owner = row.owner as Owner; const reauth = (message: string): CredentialResolutionError => @@ -1525,10 +1528,17 @@ export const createExecutor = => + ): Effect.Effect< + Redacted.Redacted | null, + StorageFailure | CredentialResolutionError + > => // Share a single refresh per connection so concurrent resolves of the same // connection all await one refresh-token grant (the AS rotates the refresh // token; parallel grants would race on a consumed token — v1's refresh @@ -1687,7 +1701,10 @@ export const createExecutor = }`). const resolveConnectionValues = ( row: ConnectionRow, - ): Effect.Effect, StorageFailure | CredentialResolutionError> => + ): Effect.Effect< + Record | null>, + StorageFailure | CredentialResolutionError + > => Effect.gen(function* () { const provider = credentialProviders.get(row.provider); if (!provider) { @@ -1702,7 +1719,7 @@ export const createExecutor = = {}; + const out: Record | null> = {}; for (const [variable, itemId] of Object.entries(connectionItemIds(row))) { out[variable] = yield* provider.get(ProviderItemId.make(itemId)); } @@ -1724,7 +1741,10 @@ export const createExecutor = => + ): Effect.Effect< + Redacted.Redacted | null, + StorageFailure | CredentialResolutionError + > => resolveConnectionValues(row).pipe( Effect.map((values) => values[PRIMARY_INPUT_VARIABLE] ?? null), ); @@ -1749,7 +1769,7 @@ export const createExecutor = => + ): Effect.Effect | null, StorageFailure> => foldResolutionFailure( Effect.gen(function* () { const row = yield* findConnectionRow(ref); @@ -1760,7 +1780,7 @@ export const createExecutor = , StorageFailure> => + ): Effect.Effect | null>, StorageFailure> => foldResolutionFailure( Effect.gen(function* () { const row = yield* findConnectionRow(ref); @@ -2793,12 +2813,20 @@ export const createExecutor = , StorageFailure> => + ): Effect.Effect | null>, StorageFailure> => Effect.gen(function* () { - const out: Record = {}; + const out: Record | null> = {}; for (const { variable, origin } of normalizeConnectionInputs(input)) { if ("value" in origin) { - out[variable] = origin.value; + // A pasted value never went through a provider. Over HTTP it is + // already wrapped (the payload schema decodes into `Redacted`); an + // in-process caller may still pass a bare string. Normalize so the + // probe sees the same shape a saved connection produces — wrapping + // an already-wrapped value would nest it and probe "". + // Nothing on this path is persisted. + out[variable] = Redacted.isRedacted(origin.value) + ? origin.value + : Redacted.make(origin.value); continue; } const provider = credentialProviders.get(String(origin.from.provider)); @@ -3341,7 +3369,7 @@ export const createExecutor = => + ): Effect.Effect | null, StorageFailure> => Effect.gen(function* () { const provider = credentialProviders.get(String(key)); if (!provider) return null; @@ -3362,7 +3390,7 @@ export const createExecutor = , ): Effect.Effect => Effect.gen(function* () { const provider = defaultWritableProvider(); diff --git a/packages/core/sdk/src/http-auth/auth-method.test.ts b/packages/core/sdk/src/http-auth/auth-method.test.ts index c178c737d..40d81de2b 100644 --- a/packages/core/sdk/src/http-auth/auth-method.test.ts +++ b/packages/core/sdk/src/http-auth/auth-method.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { Redacted } from "effect"; import { type ApiKeyAuthMethod, @@ -16,15 +17,17 @@ describe("renderAuthPlacements", () => { it("renders a bearer header from the implicit token variable", () => { expect( renderAuthPlacements([{ carrier: "header", name: "Authorization", prefix: "Bearer " }], { - token: "tok_1", + token: Redacted.make("tok_1"), }), ).toEqual({ headers: { Authorization: "Bearer tok_1" }, queryParams: {} }); }); it("renders a query param (the ui.sh '?token=' case)", () => { - expect(renderAuthPlacements([{ carrier: "query", name: "token" }], { token: "tok_1" })).toEqual( - { headers: {}, queryParams: { token: "tok_1" } }, - ); + expect( + renderAuthPlacements([{ carrier: "query", name: "token" }], { + token: Redacted.make("tok_1"), + }), + ).toEqual({ headers: {}, queryParams: { token: "tok_1" } }); }); it("mixes header and query placements in one method", () => { @@ -34,7 +37,7 @@ describe("renderAuthPlacements", () => { { carrier: "header", name: "Authorization", prefix: "Bearer ", variable: "api_token" }, { carrier: "query", name: "team_id", variable: "team_id" }, ], - { api_token: "tok_1", team_id: "team_9" }, + { api_token: Redacted.make("tok_1"), team_id: Redacted.make("team_9") }, ), ).toEqual({ headers: { Authorization: "Bearer tok_1" }, @@ -49,7 +52,7 @@ describe("renderAuthPlacements", () => { { carrier: "header", name: "DD-API-KEY", variable: "dd_api_key" }, { carrier: "header", name: "DD-APPLICATION-KEY", variable: "dd_application_key" }, ], - { dd_api_key: "a", dd_application_key: "b" }, + { dd_api_key: Redacted.make("a"), dd_application_key: Redacted.make("b") }, ), ).toEqual({ headers: { "DD-API-KEY": "a", "DD-APPLICATION-KEY": "b" }, @@ -64,7 +67,7 @@ describe("renderAuthPlacements", () => { { carrier: "header", name: "X-Token" }, { carrier: "query", name: "token" }, ], - { token: "shared" }, + { token: Redacted.make("shared") }, ), ).toEqual({ headers: { "X-Token": "shared" }, queryParams: { token: "shared" } }); }); @@ -76,7 +79,7 @@ describe("renderAuthPlacements", () => { { carrier: "header", name: "X-Api-Version", literal: "2023-01-01" }, { carrier: "header", name: "Authorization", prefix: "Bearer " }, ], - { token: "tok_1" }, + { token: Redacted.make("tok_1") }, ), ).toEqual({ headers: { "X-Api-Version": "2023-01-01", Authorization: "Bearer tok_1" }, @@ -91,7 +94,7 @@ describe("renderAuthPlacements", () => { { carrier: "header", name: "Authorization", prefix: "Bearer " }, { carrier: "query", name: "team_id", variable: "team_id" }, ], - { token: "tok_1", team_id: null }, + { token: Redacted.make("tok_1"), team_id: null }, ), ).toEqual({ headers: { Authorization: "Bearer tok_1" }, queryParams: {} }); }); @@ -124,7 +127,9 @@ describe("requiredPlacementVariables", () => { describe("oauthBearerPlacement", () => { it("defaults to the conventional Authorization: Bearer header", () => { - expect(renderAuthPlacements([oauthBearerPlacement()], { token: "at_1" })).toEqual({ + expect( + renderAuthPlacements([oauthBearerPlacement()], { token: Redacted.make("at_1") }), + ).toEqual({ headers: { Authorization: "Bearer at_1" }, queryParams: {}, }); @@ -132,7 +137,9 @@ describe("oauthBearerPlacement", () => { it("honors a custom header and prefix (graphql oauth override)", () => { expect( - renderAuthPlacements([oauthBearerPlacement("X-Access-Token", "")], { token: "at_1" }), + renderAuthPlacements([oauthBearerPlacement("X-Access-Token", "")], { + token: Redacted.make("at_1"), + }), ).toEqual({ headers: { "X-Access-Token": "at_1" }, queryParams: {} }); }); }); diff --git a/packages/core/sdk/src/http-auth/auth-method.ts b/packages/core/sdk/src/http-auth/auth-method.ts index fe1f72501..e71a9c92e 100644 --- a/packages/core/sdk/src/http-auth/auth-method.ts +++ b/packages/core/sdk/src/http-auth/auth-method.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import { Redacted, Schema } from "effect"; import type { AuthMethodDescriptor, AuthPlacementDescriptor } from "../integration"; // --------------------------------------------------------------------------- @@ -78,13 +78,22 @@ export interface RenderedAuthPlacements { readonly queryParams: Record; } +/** THE credential unwrap boundary for HTTP plugins: a placement is by + * definition a spot on the outbound request, so the string produced here goes + * straight onto the wire and nowhere else. Every consumer (mcp, openapi, + * graphql) renders through this, which is why none of them unwrap themselves. + * + * Absence is `null`, never falsiness: an empty credential is a value a no-auth + * integration legitimately binds. */ const renderPlacementValue = ( placement: AuthPlacement, - values: Record, + values: Record | null>, ): string | null => { if (placement.literal !== undefined) return placement.literal; - const value = values[placement.variable ?? TOKEN_VARIABLE]; - if (value == null) return null; + const wrapped = values[placement.variable ?? TOKEN_VARIABLE]; + if (wrapped == null) return null; + // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: THE unwrap line for HTTP plugins; the string produced here goes straight onto the outbound request + const value = Redacted.value(wrapped); return placement.prefix ? `${placement.prefix}${value}` : value; }; @@ -94,7 +103,7 @@ const renderPlacementValue = ( * unauthenticated, …) and should gate on `requiredPlacementVariables`. */ export const renderAuthPlacements = ( placements: readonly AuthPlacement[], - values: Record, + values: Record | null>, ): RenderedAuthPlacements => { const headers: Record = {}; const queryParams: Record = {}; diff --git a/packages/core/sdk/src/http-auth/authoring.test.ts b/packages/core/sdk/src/http-auth/authoring.test.ts index 3a5a22f0e..b41c6bef6 100644 --- a/packages/core/sdk/src/http-auth/authoring.test.ts +++ b/packages/core/sdk/src/http-auth/authoring.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Option, Schema } from "effect"; +import { Option, Redacted, Schema } from "effect"; import { ApiKeyAuthTemplate, apiKeyMethodFromAuthTemplate, variable } from "./authoring"; import { renderAuthPlacements } from "./auth-method"; @@ -32,7 +32,12 @@ describe("request-shaped authoring", () => { { carrier: "query", name: "team_id", variable: "team_id" }, ]); // …and the expansion renders exactly like the template reads. - expect(renderAuthPlacements(method.placements, { api_token: "tok", team_id: "t42" })).toEqual({ + expect( + renderAuthPlacements(method.placements, { + api_token: Redacted.make("tok"), + team_id: Redacted.make("t42"), + }), + ).toEqual({ headers: { Authorization: "Bearer tok" }, queryParams: { team_id: "t42" }, }); diff --git a/packages/core/sdk/src/http-auth/index.ts b/packages/core/sdk/src/http-auth/index.ts index 899452755..913661e99 100644 --- a/packages/core/sdk/src/http-auth/index.ts +++ b/packages/core/sdk/src/http-auth/index.ts @@ -14,6 +14,10 @@ export { type RenderedAuthPlacements, } from "./auth-method"; +// Tracer header redaction — a placement can mint any header name, so the +// tracer's redacted-name list has to be widened past Effect's default. +export { REDACTED_HEADER_NAMES, RedactedHeaderNamesLive } from "./redacted-headers"; + // Request-shaped authoring dialect — accepted on every plugin's auth inputs, // normalized to canonical placements at the boundary. export { diff --git a/packages/core/sdk/src/http-auth/redacted-headers.test.ts b/packages/core/sdk/src/http-auth/redacted-headers.test.ts new file mode 100644 index 000000000..cb668ed1e --- /dev/null +++ b/packages/core/sdk/src/http-auth/redacted-headers.test.ts @@ -0,0 +1,92 @@ +// --------------------------------------------------------------------------- +// The tracer's redacted-header list must cover the headers THIS app mints. +// +// Effect's tracing seams stamp every header as a span attribute and wrap only +// the names in `Headers.CurrentRedactedNames`. An `AuthPlacement` names its own +// header, so the list has to cover names a spec's `secretHeaders` produced — +// not just Effect's four defaults. Values here are synthetic. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; +import { Redacted } from "effect"; +import { Headers } from "effect/unstable/http"; + +import { REDACTED_HEADER_NAMES } from "./redacted-headers"; + +const SECRET = "synthetic-credential-value"; + +const redactedNames = (headers: Record): readonly string[] => { + const redacted = Headers.redact(Headers.fromRecordUnsafe(headers), REDACTED_HEADER_NAMES); + return Object.entries(redacted) + .filter(([, value]) => Redacted.isRedacted(value)) + .map(([name]) => name) + .sort(); +}; + +describe("REDACTED_HEADER_NAMES", () => { + it("covers the header names an auth placement mints", () => { + // Every name here is reachable from a real placement: `authoring.ts` + // accepts an arbitrary header name, and `derive-auth.ts` builds placements + // straight from a spec's `secretHeaders`. + const minted = { + authorization: `Bearer ${SECRET}`, + "x-api-key": SECRET, + "api-key": SECRET, + "x-figma-token": SECRET, + "dd-api-key": SECRET, + "private-token": SECRET, + "x-auth-token": SECRET, + "x-access-token": SECRET, + "x-session-token": SECRET, + "x-hub-signature": SECRET, + "client-secret": SECRET, + }; + + expect(redactedNames(minted)).toEqual(Object.keys(minted).sort()); + }); + + it("covers Effect's own defaults plus the standard auth headers it omits", () => { + expect( + redactedNames({ + authorization: `Bearer ${SECRET}`, + cookie: `wos-session=${SECRET}`, + "set-cookie": `wos-session=${SECRET}`, + "proxy-authorization": `Basic ${SECRET}`, + "www-authenticate": `Bearer realm="executor", error="invalid_token"`, + }), + ).toEqual(["authorization", "cookie", "proxy-authorization", "set-cookie", "www-authenticate"]); + }); + + it("leaves ordinary headers stamped in plaintext", () => { + // Over-matching is cheap (a redacted attribute still shows the header was + // present) but it must not swallow the headers that make a trace useful. + expect( + redactedNames({ + "content-type": "application/json", + "user-agent": "executor/1.0", + "x-request-id": "synthetic-request-id", + accept: "application/json", + "cache-control": "no-cache", + "x-executor-organization": "acme", + traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + }), + ).toEqual([]); + }); + + it("keeps the trace-correlation headers a bare `key` / `session` segment would swallow", () => { + // These name a correlation value, not a credential, and they are precisely + // the headers a retry or a session-scoped bug is read through. Pinned so a + // future widening of the shape has to argue with this case rather than + // silently blanking them. + expect( + redactedNames({ + "idempotency-key": "synthetic-idempotency-key", + "x-idempotency-key": "synthetic-idempotency-key", + "session-id": "synthetic-session-id", + "x-session-id": "synthetic-session-id", + "mcp-session-id": "synthetic-session-id", + "partition-key": "synthetic-partition-key", + }), + ).toEqual([]); + }); +}); diff --git a/packages/core/sdk/src/http-auth/redacted-headers.ts b/packages/core/sdk/src/http-auth/redacted-headers.ts new file mode 100644 index 000000000..128fd5959 --- /dev/null +++ b/packages/core/sdk/src/http-auth/redacted-headers.ts @@ -0,0 +1,54 @@ +// --------------------------------------------------------------------------- +// Which header names a tracer must never stamp verbatim. +// +// Effect's tracing seams (`HttpMiddleware.tracer` inbound, `HttpClient` +// outbound) stamp EVERY header as a span attribute and wrap only the names in +// `Headers.CurrentRedactedNames` — whose default is just authorization, cookie, +// set-cookie, and x-api-key. This app mints credential headers well outside +// that list: an `AuthPlacement` names its own header (`authoring.ts` / +// `legacy.ts`), so a connection's key rides on whatever the integration +// declared — `X-Figma-Token`, `DD-API-KEY`, `Private-Token`, anything a spec's +// `secretHeaders` produced. Left at the default, every one of those was stamped +// in plaintext on the `http.client` span of every tool invocation. +// +// Placement names are user-supplied, so they cannot be enumerated. They are +// matched by SHAPE instead, and the match is deliberately loose: a redacted +// span attribute still records that the header was present (its value renders +// as ``), so over-matching costs nothing, while under-matching ships +// a credential. +// --------------------------------------------------------------------------- + +import { Headers } from "effect/unstable/http"; +import { Layer } from "effect"; + +/** Credential vocabulary, matched against a whole header name segment so + * `x-request-id` and `content-type` are untouched while `api-key`, + * `x-figma-token`, and `private-token` are not. + * + * A bare `key` and a bare `session` segment are deliberately absent. Both name + * trace-correlation values far more often than credentials — `idempotency-key` + * and `session-id` are exactly the headers that make a trace worth reading, + * and neither is a bearer of anything. The credential spellings that DO carry + * one are still covered by their qualified forms (`api-key`, `access-key`, + * `session-token`), so this narrows the collateral without narrowing the + * guarantee. */ +const CREDENTIAL_HEADER_SHAPE = + /(^|-)(api[-_]?key|access[-_]?key|auth|authentication|auth[-_]?token|access[-_]?token|credential|password|pat|secret|session[-_]?key|session[-_]?token|signature|token)(-|$)/i; + +/** The names this app's tracers redact: Effect's defaults, the standard auth + * headers it omits, and the credential shape an `AuthPlacement` can mint. */ +export const REDACTED_HEADER_NAMES: readonly (string | RegExp)[] = [ + "authorization", + "cookie", + "set-cookie", + "proxy-authorization", + "www-authenticate", + CREDENTIAL_HEADER_SHAPE, +]; + +/** Provide the widened list wherever an HTTP layer is assembled — inbound + * server middleware and outbound clients alike. Both read the same reference, + * so one layer covers a surface's spans in both directions. */ +export const RedactedHeaderNamesLive: Layer.Layer = Layer.succeed( + Headers.CurrentRedactedNames, +)(REDACTED_HEADER_NAMES); diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 2bf3d8797..a5bc6d394 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -100,6 +100,8 @@ export type { Tool, ToolDef, ToolListFilter, ToolAnnotations } from "./tool"; // Credential providers. export type { CredentialProvider, ProviderEntry } from "./provider"; +export { credentialValueToWrite } from "./provider"; +export { makeCredentialScrubber, type CredentialScrubber } from "./credential-scrub"; // Public projections / detection. export { ToolSchemaView, IntegrationDetectionResult } from "./types"; @@ -250,6 +252,7 @@ export { OAuthProbeError, OAuthRegisterDynamicError, OAuthSessionNotFoundError, + oauthClientSecretFromInput, type OAuthGrant, type OAuthAuthentication, type OAuthClient, diff --git a/packages/core/sdk/src/oauth-client.ts b/packages/core/sdk/src/oauth-client.ts index 7537a5637..ace43b923 100644 --- a/packages/core/sdk/src/oauth-client.ts +++ b/packages/core/sdk/src/oauth-client.ts @@ -1,5 +1,5 @@ import type { Effect } from "effect"; -import { Schema } from "effect"; +import { Redacted, Schema } from "effect"; import type { Connection } from "./connection"; import type { UserActionableError } from "./errors"; @@ -56,14 +56,41 @@ export interface OAuthClient { readonly tokenUrl: string; readonly grant: OAuthGrant; readonly clientId: string; - /** The literal client secret. Stored out-of-band in the credential provider - * (vault item id), never inline. Empty string for public / PKCE clients. */ - readonly clientSecret: string; + /** The client secret, or `null` for a public / PKCE client. Stored out-of-band + * in the credential provider (vault item id), never inline. Accepts a bare + * string as well as `Redacted` so a pasted form field needs no wrapping; + * everything downstream of `createClient` carries it wrapped. + * Presence is `null`, never `""` and never `Redacted.make("")`: an empty + * string is a value and every `Redacted` is truthy, so any falsiness or + * emptiness test on it is a bug waiting to happen. Convert form/wire input + * through `oauthClientSecretFromInput` at the boundary. */ + readonly clientSecret: string | Redacted.Redacted | null; /** RFC 8707 Resource Indicator (MCP). Carried so the refresh request can keep * the re-minted token bound to the same resource. Null/omitted otherwise. */ readonly resource?: string | null; } +/** Presence-normalize a client secret arriving from a form field, the wire, or a + * DCR response, where "absent" can only be spelled as the empty string. + * Emptiness is tested on the UNWRAPPED value, per `clientSecret` above. The + * wrapper is preserved — normalizing does not unwrap. + * Deliberately does NOT trim: a whitespace-bearing secret is a value, and + * trimming it here would silently downgrade a confidential client to a public + * one. Callers that want the user's leading/trailing whitespace dropped trim + * before calling. */ +export function oauthClientSecretFromInput(value: string | null | undefined): string | null; +export function oauthClientSecretFromInput>( + value: A | null | undefined, +): A | null; +export function oauthClientSecretFromInput( + value: string | Redacted.Redacted | null | undefined, +): string | Redacted.Redacted | null { + if (value == null) return null; + // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: only the length is read; the wrapper is what is returned + const literal = Redacted.isRedacted(value) ? Redacted.value(value) : value; + return literal.length === 0 ? null : value; +} + export type OAuthClientOrigin = | { readonly kind: "manual"; diff --git a/packages/core/sdk/src/oauth-discovery.ts b/packages/core/sdk/src/oauth-discovery.ts index 25786012c..ab5a4a702 100644 --- a/packages/core/sdk/src/oauth-discovery.ts +++ b/packages/core/sdk/src/oauth-discovery.ts @@ -18,7 +18,18 @@ // call callers actually need. // --------------------------------------------------------------------------- -import { Data, Duration, Effect, Layer, Option, Predicate, Result, Schema } from "effect"; +import { + Data, + Duration, + Effect, + Layer, + Option, + Predicate, + Redacted, + Result, + Schema, + SchemaGetter, +} from "effect"; import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; import { @@ -27,6 +38,7 @@ import { buildAuthorizationUrl, createPkceCodeChallenge, createPkceCodeVerifier, + type OAuth2SecretInput, type OAuthEndpointUrlPolicy, } from "./oauth-helpers"; @@ -106,12 +118,29 @@ export type DynamicClientMetadata = { readonly extra?: Readonly>; }; +/** A bidirectional string ⇄ `Redacted` codec. `Schema.Redacted` and + * `Schema.RedactedFromValue` forbid encoding, which would make any struct + * carrying one decode-only; DCR client information round-trips (it is cached + * and replayed through `previousState`), so the encode side is required. */ +const RedactedString = Schema.String.pipe( + Schema.decodeTo(Schema.Redacted(Schema.String), { + decode: SchemaGetter.transform(Redacted.make), + // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the encode side of the DCR client-information codec, which round-trips through the cache + encode: SchemaGetter.transform(Redacted.value), + }), +); + export const OAuthClientInformationSchema = Schema.Struct({ client_id: Schema.String, - client_secret: Schema.optional(Schema.String), + /** Minted by the AS for a confidential DCR client. Redacted from the moment + * the registration response is decoded — it is posted to the token endpoint + * and stored in the credential provider, and reaches nothing else. */ + client_secret: Schema.optional(RedactedString), client_id_issued_at: Schema.optional(Schema.Number), client_secret_expires_at: Schema.optional(Schema.Number), - registration_access_token: Schema.optional(Schema.String), + /** RFC 7592 bearer token for the client's own registration resource — as + * privileged as the client secret. */ + registration_access_token: Schema.optional(RedactedString), registration_client_uri: Schema.optional(Schema.String), token_endpoint_auth_method: Schema.optional(Schema.String), grant_types: Schema.optional(StringArray), @@ -413,7 +442,7 @@ export const discoverAuthorizationServerMetadata = ( export interface RegisterDynamicClientInput { readonly registrationEndpoint: string; readonly metadata: DynamicClientMetadata; - readonly initialAccessToken?: string | null; + readonly initialAccessToken?: OAuth2SecretInput | null; } // Internal failure modes — collapsed into `OAuthDiscoveryError` at the @@ -513,8 +542,12 @@ export const registerDynamicClient = ( "content-type": "application/json", accept: "application/json", }; - if (input.initialAccessToken) { - headers.authorization = `Bearer ${input.initialAccessToken}`; + if (input.initialAccessToken != null) { + const token = Redacted.isRedacted(input.initialAccessToken) + ? // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the RFC 7591 initial access token goes out as a bearer header + Redacted.value(input.initialAccessToken) + : input.initialAccessToken; + if (token.length > 0) headers.authorization = `Bearer ${token}`; } let request = HttpClientRequest.post(input.registrationEndpoint).pipe( @@ -661,7 +694,9 @@ export interface DynamicAuthorizationState { export interface DynamicAuthorizationStartResult { readonly authorizationUrl: string; - readonly codeVerifier: string; + /** Grant material — the caller holds it until the matching token exchange. + * Unwrap only at that exchange or at the line that persists it. */ + readonly codeVerifier: Redacted.Redacted; readonly state: DynamicAuthorizationState; } diff --git a/packages/core/sdk/src/oauth-flow.test.ts b/packages/core/sdk/src/oauth-flow.test.ts index 8869b81ab..5d98ab159 100644 --- a/packages/core/sdk/src/oauth-flow.test.ts +++ b/packages/core/sdk/src/oauth-flow.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Predicate } from "effect"; +import { Effect, Predicate, Redacted } from "effect"; +import { HttpServerResponse } from "effect/unstable/http"; import { AuthTemplateSlug, @@ -10,11 +11,13 @@ import { ToolAddress, ToolName, } from "./ids"; +import { StorageError } from "./fuma-runtime"; import { decodeOAuthCallbackState } from "./oauth"; -import { OAuthStartError } from "./oauth-client"; +import { OAuthProbeError, OAuthStartError } from "./oauth-client"; import { missingGrantedOAuthScopes } from "./oauth-service"; import { definePlugin } from "./plugin"; import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; +import { serveTestHttpApp } from "./testing"; import { serveOAuthTestServer } from "./testing/oauth-test-server"; // Milestone 2: prove the v2 `oauth.start` / `oauth.complete` token-minting flow @@ -44,8 +47,13 @@ const oauthPlugin = definePlugin(() => ({ }, ]; }, - // Echo the resolved credential value (the OAuth access token) back out. - invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }), + // Echo the resolved credential value (the OAuth access token) back out. The + // result is a wire payload, so unwrap — a `Redacted` would serialize as the + // literal "" and the assertions below would pass on nothing. + invokeTool: ({ credential }) => + Effect.succeed({ + token: credential.value === null ? null : Redacted.value(credential.value), + }), checkHealth: ({ credential }) => Effect.succeed({ status: credential.value === null ? "expired" : "healthy", @@ -1179,6 +1187,332 @@ describe("oauth.complete regional token-endpoint rebind (Datadog multi-site)", ( ); }); +// --------------------------------------------------------------------------- +// Client-secret presence. `null` is the ONLY spelling of "public / PKCE +// client"; a non-empty string is the only spelling of "confidential". The +// empty string used to mean "public", which is a trap the moment the secret +// becomes a wrapper type whose empty value is truthy — so it is now rejected +// outright rather than silently registering a confidential app with an empty +// secret. These cases pin the wire consequence, not just the field. +// --------------------------------------------------------------------------- + +const tokenGrantBodies = ( + requests: readonly { readonly path: string; readonly method: string; readonly body: string }[], +): readonly URLSearchParams[] => + requests + .filter((r) => r.path === "/token" && r.method === "POST") + .map((r) => new URLSearchParams(r.body)) + .filter((body) => body.has("grant_type")); + +describe("oauth client secret presence", () => { + it.effect("a null-secret client authenticates as public: no client_secret on the wire", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ + scopes: ["read"], + clients: { "public-pkce-client": null }, + }); + const { config, executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(["read"]); + + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "public-pkce-client", + clientSecret: null, + }); + + // Nothing was written to the credential provider: there is no secret. + const row = yield* Effect.promise(() => + config.db.findFirst("oauth_client", { where: (b) => b("slug", "=", String(CLIENT)) }), + ); + expect(row?.client_secret_item_id ?? null).toBeNull(); + + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + yield* executor.oauth.complete({ state: started.state, code: callback.code }); + + const grants = tokenGrantBodies(yield* server.requests); + expect(grants).toHaveLength(1); + expect(grants[0]!.get("client_id")).toBe("public-pkce-client"); + expect(grants[0]!.has("client_secret")).toBe(false); + }), + ), + ); + + it.effect("a confidential client keeps client_secret_post on the token request", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { config, executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(["read"]); + + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + }); + + const row = yield* Effect.promise(() => + config.db.findFirst("oauth_client", { where: (b) => b("slug", "=", String(CLIENT)) }), + ); + expect(row?.client_secret_item_id ?? null).not.toBeNull(); + + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + yield* executor.oauth.complete({ state: started.state, code: callback.code }); + + const grants = tokenGrantBodies(yield* server.requests); + expect(grants).toHaveLength(1); + expect(grants[0]!.get("client_secret")).toBe("test-secret"); + }), + ), + ); + + it.effect("rejects the empty string rather than guessing public or confidential", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { config, executor } = yield* makeTestWorkspaceHarness({ plugins }); + + const error = yield* Effect.flip( + executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "", + }), + ); + // `StorageError` carries a typed `message`; the guard narrows the + // StorageFailure union so this read is on a typed failure. + expect(Predicate.isTagged("StorageError")(error)).toBe(true); + const storageError = error as StorageError; + expect(storageError.message).toContain("Invalid OAuth client secret"); + + // The rejection is total: no half-registered row survives it. + const row = yield* Effect.promise(() => + config.db.findFirst("oauth_client", { where: (b) => b("slug", "=", String(CLIENT)) }), + ); + expect(row ?? null).toBeNull(); + }), + ), + ); +}); + +// --------------------------------------------------------------------------- +// Persisted bytes. +// +// A missed unwrap on a WRITE path does not throw: `Redacted`'s toString/toJSON +// render "", so the literal is stored and the failure only surfaces +// later, as an authorization error against a provider. These assert the stored +// value, not the in-memory one — the in-memory half is covered in +// `oauth-helpers.test.ts`. +// --------------------------------------------------------------------------- + +describe("OAuth material persists as its value, never as the redaction marker", () => { + it.effect("the minted access + refresh tokens and the PKCE verifier store literally", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { config, executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(["read"]); + + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + }); + + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + + // The session's PKCE verifier is written by `start`. If it stored + // "" the exchange below would fail RFC 7636 validation — but + // assert the column too, so the reason is legible when it does. + const sessionRow = yield* Effect.promise(() => + config.db.findFirst("oauth_session", { + where: (b) => b("state", "=", String(started.state)), + }), + ); + const storedVerifier = String(sessionRow?.pkce_verifier); + expect(storedVerifier).not.toBe(""); + expect(storedVerifier).toMatch(/^[A-Za-z0-9_-]{43,128}$/); + + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + yield* executor.oauth.complete({ state: started.state, code: callback.code }); + + // The stored access token is the one the AS issued: the tool echoes the + // resolved credential, and the server still honours it. + const echoed = (yield* executor.execute( + ToolAddress.make("tools.acme.org.main.whoami"), + {}, + )) as { token: string }; + expect(echoed.token).not.toBe(""); + expect(echoed.token).toMatch(/^at_/); + expect(yield* server.acceptsAccessToken(echoed.token)).toBe(true); + + // The refresh token is stored under its own item id and must be equally + // literal — nothing reads it until a refresh, so a "" write + // here would lie dormant until the access token first expired. + const connectionRow = yield* Effect.promise(() => + config.db.findFirst("connection", { where: (b) => b("name", "=", "main") }), + ); + expect(connectionRow?.refresh_item_id ?? null).not.toBeNull(); + + // Force expiry so the refresh grant runs against the stored refresh + // token. The AS matches it by exact string, so a grant that succeeds is + // proof the token round-tripped through the provider byte for byte. + yield* Effect.promise(() => + config.db.updateMany("connection", { + where: (b) => b("name", "=", "main"), + set: { expires_at: Date.now() - 60_000 }, + }), + ); + const refreshed = (yield* executor.execute( + ToolAddress.make("tools.acme.org.main.whoami"), + {}, + )) as { token: string }; + expect(refreshed.token).not.toBe(""); + expect(refreshed.token).toMatch(/^at_/); + expect(refreshed.token).not.toBe(echoed.token); + expect(yield* server.acceptsAccessToken(refreshed.token)).toBe(true); + }), + ), + ); + + it.effect("a DCR-minted client secret authenticates instead of storing ", () => + // The DCR secret is decoded straight out of the registration response into + // a `Redacted` field, then written to the provider and later posted to the + // token endpoint. A missed unwrap anywhere on that path stores the marker + // and the AS rejects the grant with `invalid_client`. + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { config, executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(["read"]); + + const slug = yield* executor.oauth.registerDynamicClient({ + owner: "org", + slug: CLIENT, + issuer: server.issuerUrl, + registrationEndpoint: server.registrationEndpoint, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + scopes: ["read"], + // Advertising no `none` makes DCR register a CONFIDENTIAL client, so + // the server mints a secret we must carry back to /token. + tokenEndpointAuthMethodsSupported: ["client_secret_post"], + }); + + const row = yield* Effect.promise(() => + config.db.findFirst("oauth_client", { where: (b) => b("slug", "=", String(slug)) }), + ); + expect(row?.client_secret_item_id ?? null).not.toBeNull(); + + const started = yield* executor.oauth.start({ + owner: "org", + client: slug, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + // The exchange succeeding IS the assertion: the test server rejects a + // mismatched client_secret with invalid_client. + yield* executor.oauth.complete({ state: started.state, code: callback.code }); + + const grant = tokenGrantBodies(yield* server.requests).at(0); + expect(grant?.get("client_secret")).not.toBe(""); + expect(grant?.get("client_secret") ?? "").not.toBe(""); + }), + ), + ); +}); + +describe("oauth.probe error message sanitization", () => { + it.effect("echoes the probed URL without its query string or userinfo", () => + // The probe URL is a raw user paste and a supported credential carrier (an + // MCP endpoint with `?token=…`, `user:pass@host`). Its failure message + // reaches the connect UI and the logs, so it may name the endpoint but + // never the credential riding on it. + Effect.scoped( + Effect.gen(function* () { + // A loopback server with no OAuth metadata anywhere: the probe finds + // nothing and takes the "no metadata found" branch that echoes the URL. + const server = yield* serveTestHttpApp(() => + Effect.succeed(HttpServerResponse.empty({ status: 404 })), + ); + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + + const probed = new URL(server.url("/mcp")); + probed.searchParams.set("token", "probe-query-credential"); + probed.username = "probe-user"; + probed.password = "probe-password"; + + const error = yield* Effect.flip(executor.oauth.probe({ url: probed.toString() })); + expect(Predicate.isTagged("OAuthProbeError")(error)).toBe(true); + const probeError = error as OAuthProbeError; + // The endpoint itself still names the failure — sanitizing must not + // cost the diagnostic. + expect(probeError.message).toContain("/mcp"); + expect(probeError.message).not.toContain("probe-query-credential"); + expect(probeError.message).not.toContain("probe-password"); + }), + ), + ); +}); + describe("missingGrantedOAuthScopes canonicalization", () => { it("treats Microsoft fully-qualified granted scopes as covering short-form requests", () => { const missing = missingGrantedOAuthScopes( diff --git a/packages/core/sdk/src/oauth-helpers.test.ts b/packages/core/sdk/src/oauth-helpers.test.ts index 80b171f7a..d87139056 100644 --- a/packages/core/sdk/src/oauth-helpers.test.ts +++ b/packages/core/sdk/src/oauth-helpers.test.ts @@ -6,7 +6,7 @@ // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Ref } from "effect"; +import { Effect, Exit, Logger, Redacted, Ref } from "effect"; import { HttpServerResponse } from "effect/unstable/http"; import { @@ -15,6 +15,7 @@ import { OAuth2Error, buildAuthorizationUrl, providerAuthorizeExtras, + createOAuthState, createPkceCodeChallenge, createPkceCodeVerifier, exchangeAuthorizationCode, @@ -99,6 +100,11 @@ const tokenResponse = () => json(200, body); +/** Assert on the token VALUE, not on `Redacted`'s "" rendering — a + * `toBe` against the wrapper would pass for any implementation. */ +const secretOf = (value: Redacted.Redacted | undefined): string | undefined => + value === undefined ? undefined : Redacted.value(value); + // --------------------------------------------------------------------------- // PKCE // --------------------------------------------------------------------------- @@ -106,7 +112,7 @@ const tokenResponse = describe("PKCE", () => { it("createPkceCodeVerifier returns a base64url string in the RFC 7636 length range", () => { for (let i = 0; i < 25; i++) { - const verifier = createPkceCodeVerifier(); + const verifier = Redacted.value(createPkceCodeVerifier()); expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/); expect(verifier.length).toBeGreaterThanOrEqual(43); expect(verifier.length).toBeLessThanOrEqual(128); @@ -117,13 +123,26 @@ describe("PKCE", () => { const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; const expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; expect(await createPkceCodeChallenge(verifier)).toBe(expected); + // A wrapped verifier hashes identically — the challenge is computed from + // the value, not from `Redacted`'s "" rendering. + expect(await createPkceCodeChallenge(Redacted.make(verifier))).toBe(expected); }); it("createPkceCodeVerifier produces unique values", () => { const seen = new Set(); - for (let i = 0; i < 50; i++) seen.add(createPkceCodeVerifier()); + for (let i = 0; i < 50; i++) seen.add(Redacted.value(createPkceCodeVerifier())); expect(seen.size).toBe(50); }); + + it("PKCE and state material serializes as rather than its value", () => { + // These are minted long before they reach a DB column or an authorize URL, + // so anything that stringifies them in between (a log line, a span + // attribute, a session dump) must not print the grant material. + for (const secret of [createPkceCodeVerifier(), createOAuthState()]) { + expect(JSON.stringify({ secret })).toBe('{"secret":""}'); + expect(String(secret)).not.toContain(Redacted.value(secret)); + } + }); }); // --------------------------------------------------------------------------- @@ -259,7 +278,7 @@ describe("exchangeAuthorizationCode", () => { codeVerifier: "verifier", code: "abc", }); - expect(result.access_token).toBe("tok"); + expect(secretOf(result.access_token)).toBe("tok"); const call = (yield* calls)[0]!; expect(call.method).toBe("POST"); expect(call.headers["content-type"]).toMatch(/^application\/x-www-form-urlencoded/); @@ -308,6 +327,43 @@ describe("exchangeAuthorizationCode", () => { ), ); + it.effect("omits client_secret for an explicitly null secret (public PKCE client)", () => + withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) => + Effect.gen(function* () { + yield* exchangeAuthorizationCode({ + tokenUrl, + clientId: "cid", + clientSecret: null, + redirectUrl: "https://app.example.com/cb", + codeVerifier: "verifier", + code: "abc", + }); + const body = (yield* calls)[0]!.body; + expect(body.get("client_id")).toBe("cid"); + expect(body.has("client_secret")).toBe(false); + }), + ), + ); + + it.effect("authenticates with a whitespace-only secret rather than treating it as public", () => + // A confidential client whose secret happens to be whitespace is still + // confidential. `null` is the ONLY spelling of "public", so this must + // authenticate — the old falsiness test silently downgraded it. + withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) => + Effect.gen(function* () { + yield* exchangeAuthorizationCode({ + tokenUrl, + clientId: "cid", + clientSecret: " ", + redirectUrl: "https://app.example.com/cb", + codeVerifier: "verifier", + code: "abc", + }); + expect((yield* calls)[0]!.body.get("client_secret")).toBe(" "); + }), + ), + ); + it.effect("includes RFC 8707 resource parameter on the token request when provided", () => withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) => Effect.gen(function* () { @@ -361,8 +417,8 @@ describe("exchangeAuthorizationCode", () => { codeVerifier: "verifier", code: "abc", }); - expect(result.access_token).toBe("tok"); - expect(result.refresh_token).toBe("rtok"); + expect(secretOf(result.access_token)).toBe("tok"); + expect(secretOf(result.refresh_token)).toBe("rtok"); expect(result.idTokenIdentityLabel).toBe("user-1"); }), ), @@ -436,7 +492,7 @@ describe("exchangeAuthorizationCode", () => { codeVerifier: "verifier", code: "abc", }); - expect(result.access_token).toBe("tok"); + expect(secretOf(result.access_token)).toBe("tok"); expect(result.idTokenIdentityLabel).toBeUndefined(); }), ), @@ -464,7 +520,7 @@ describe("exchangeAuthorizationCode", () => { codeVerifier: "verifier", code: "abc", }); - expect(result.access_token).toBe("tok"); + expect(secretOf(result.access_token)).toBe("tok"); }), ), ); @@ -479,8 +535,8 @@ describe("exchangeAuthorizationCode", () => { codeVerifier: "verifier", code: "abc", }); - expect(result.access_token).toBe("tok"); - expect(result.refresh_token).toBe("rtok"); + expect(secretOf(result.access_token)).toBe("tok"); + expect(secretOf(result.refresh_token)).toBe("rtok"); expect(result.expires_in).toBe(3600); expect(result.idTokenIdentityLabel).toBeUndefined(); }), @@ -540,8 +596,8 @@ describe("exchangeAuthorizationCode", () => { codeVerifier: "verifier", code: "abc", }); - expect(result.access_token).toBe("tok"); - expect(result.refresh_token).toBe("rtok"); + expect(secretOf(result.access_token)).toBe("tok"); + expect(secretOf(result.refresh_token)).toBe("rtok"); }), ), ); @@ -682,6 +738,120 @@ describe("exchangeAuthorizationCode", () => { }), ), ); + + it.effect("redacts every grant credential a JSON error body can echo back", () => + // The token endpoint echoes the request it rejected. `code`, + // `code_verifier`, `device_code`, and the assertion family are all + // redeemable grant material, so a summary that quotes any of them hands a + // log reader a working credential. The nested `error` object is what makes + // this body non-conforming, which is the shape that reaches the summary. + withTokenEndpoint( + () => + json(400, { + error: { code: "invalid_client_id", message: "Invalid client_id" }, + code: "echoed-authorization-code", + code_verifier: "echoed-pkce-verifier", + device_code: "echoed-device-code", + assertion: "echoed-assertion", + client_assertion: "echoed-client-assertion", + }), + ({ tokenUrl }) => + Effect.gen(function* () { + const exit = yield* Effect.exit( + exchangeAuthorizationCode({ + tokenUrl, + clientId: "cid", + redirectUrl: "https://cb", + codeVerifier: "v", + code: "c", + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + const failure = JSON.stringify(exit.cause); + // The provider's error label still survives — redaction must not cost + // the diagnostic that makes the summary worth emitting. + expect(failure).toContain("invalid_client_id"); + for (const secret of [ + "echoed-authorization-code", + "echoed-pkce-verifier", + "echoed-device-code", + "echoed-assertion", + "echoed-client-assertion", + ]) { + expect(failure).not.toContain(secret); + } + }), + ), + ); + + it.effect("redacts every grant credential a form-encoded error body can echo back", () => + withTokenEndpoint( + () => + HttpServerResponse.text( + "error=invalid_grant&code=echoed-authorization-code&code_verifier=echoed-pkce-verifier" + + "&device_code=echoed-device-code&assertion=echoed-assertion" + + "&client_assertion=echoed-client-assertion", + { status: 400 }, + ), + ({ tokenUrl }) => + Effect.gen(function* () { + const exit = yield* Effect.exit( + exchangeAuthorizationCode({ + tokenUrl, + clientId: "cid", + redirectUrl: "https://cb", + codeVerifier: "v", + code: "c", + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + const failure = JSON.stringify(exit.cause); + expect(failure).toContain("invalid_grant"); + for (const secret of [ + "echoed-authorization-code", + "echoed-pkce-verifier", + "echoed-device-code", + "echoed-assertion", + "echoed-client-assertion", + ]) { + expect(failure).not.toContain(secret); + } + }), + ), + ); + + it.effect("strips query-string credentials from the summarized token URL", () => + // The token endpoint is configured per OAuth client, so its URL can itself + // carry a credential — and `fetch` preserves the query string on + // `response.url`, so an unsanitized summary republishes it into a + // user-visible message. The summary keeps scheme/host/path (the debuggable + // part) and drops the query. + withTokenEndpoint( + () => HttpServerResponse.text("route not found", { status: 404 }), + ({ tokenUrl }) => + Effect.gen(function* () { + const url = new URL(tokenUrl); + url.searchParams.set("api_key", "endpoint-query-credential"); + const exit = yield* Effect.exit( + exchangeAuthorizationCode({ + tokenUrl: url.toString(), + clientId: "cid", + redirectUrl: "https://cb", + codeVerifier: "v", + code: "c", + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + const failure = JSON.stringify(exit.cause); + expect(failure).toContain("HTTP 404"); + expect(failure).toContain("/token"); + expect(failure).not.toContain("endpoint-query-credential"); + }), + ), + ); }); describe("exchangeClientCredentials", () => { @@ -825,7 +995,7 @@ describe("refreshAccessToken", () => { clientId: "cid", refreshToken: "old", }); - expect(result.access_token).toBe("tok2"); + expect(secretOf(result.access_token)).toBe("tok2"); }), ), ); @@ -853,7 +1023,7 @@ describe("refreshAccessToken", () => { clientId: "cid", refreshToken: "old", }); - expect(result.access_token).toBe("tok2"); + expect(secretOf(result.access_token)).toBe("tok2"); }), ), ); @@ -866,13 +1036,207 @@ describe("refreshAccessToken", () => { clientId: "cid", refreshToken: "old", }); - expect(result.access_token).toBe("tok2"); + expect(secretOf(result.access_token)).toBe("tok2"); expect(result.expires_in).toBe(3600); }), ), ); }); +// --------------------------------------------------------------------------- +// Token material is Redacted end to end. +// +// Two halves, both required: the WIRE half (the secret still reaches the token +// endpoint byte for byte, so the wrapping is not merely decorative) and the +// LEAK half (a failed exchange's error and log output quotes neither the tokens +// the caller sent nor the ones the AS echoed back). Either half alone passes +// trivially for a broken implementation. +// --------------------------------------------------------------------------- + +// Synthetic — not shaped like any real provider's token. +const SENT_REFRESH = "synthetic-sent-refresh-value"; +const SENT_SECRET = "synthetic-sent-client-secret"; +const ECHOED_ACCESS = "synthetic-echoed-access-value"; +const ECHOED_REFRESH = "synthetic-echoed-refresh-value"; + +describe("OAuth token material as Redacted", () => { + it.effect("a wrapped refresh token and client secret reach the token endpoint intact", () => + withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) => + Effect.gen(function* () { + yield* refreshAccessToken({ + tokenUrl, + clientId: "cid", + clientSecret: Redacted.make(SENT_SECRET), + refreshToken: Redacted.make(SENT_REFRESH), + }); + const body = (yield* calls)[0]!.body; + expect(body.get("refresh_token")).toBe(SENT_REFRESH); + expect(body.get("client_secret")).toBe(SENT_SECRET); + }), + ), + ); + + it.effect("a wrapped code and PKCE verifier reach the token endpoint intact", () => + withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) => + Effect.gen(function* () { + yield* exchangeAuthorizationCode({ + tokenUrl, + clientId: "cid", + clientSecret: Redacted.make(SENT_SECRET), + redirectUrl: "https://app.example.com/cb", + codeVerifier: Redacted.make("synthetic-verifier-value"), + code: Redacted.make("synthetic-code-value"), + }); + const body = (yield* calls)[0]!.body; + expect(body.get("code")).toBe("synthetic-code-value"); + expect(body.get("code_verifier")).toBe("synthetic-verifier-value"); + expect(body.get("client_secret")).toBe(SENT_SECRET); + }), + ), + ); + + it.effect("a successful exchange returns tokens that serialize as ", () => + // Distinct synthetic values rather than the shared `validCodeBody` fixture: + // its "tok" is a substring of the `token_type` key, so a not-to-contain + // assertion against it could never be trusted. + withTokenEndpoint( + tokenResponse({ + access_token: ECHOED_ACCESS, + token_type: "Bearer", + refresh_token: ECHOED_REFRESH, + expires_in: 3600, + }), + ({ tokenUrl }) => + Effect.gen(function* () { + const result = yield* exchangeAuthorizationCode({ + tokenUrl, + clientId: "cid", + redirectUrl: "https://app.example.com/cb", + codeVerifier: "v", + code: "c", + }); + // The values survive an explicit unwrap … + expect(secretOf(result.access_token)).toBe(ECHOED_ACCESS); + expect(secretOf(result.refresh_token)).toBe(ECHOED_REFRESH); + // … but a caller that forgets one serializes the marker, not the token. + const serialized = JSON.stringify(result); + expect(serialized).toContain('"access_token":""'); + expect(serialized).toContain('"refresh_token":""'); + expect(serialized).not.toContain(ECHOED_ACCESS); + expect(serialized).not.toContain(ECHOED_REFRESH); + }), + ), + ); + + it.effect("an AS that returns no refresh_token yields undefined, not a wrapped empty", () => + // Presence must survive the wrapping: a wrapped empty here would mark the + // connection refreshable when it is not. + withTokenEndpoint( + tokenResponse({ access_token: "tok", token_type: "Bearer", expires_in: 3600 }), + ({ tokenUrl }) => + Effect.gen(function* () { + const result = yield* exchangeAuthorizationCode({ + tokenUrl, + clientId: "cid", + redirectUrl: "https://app.example.com/cb", + codeVerifier: "v", + code: "c", + }); + expect(result.refresh_token).toBeUndefined(); + expect(Redacted.isRedacted(result.access_token)).toBe(true); + }), + ), + ); + + it.effect("a failed exchange leaks neither the sent nor the echoed token material", () => + // The token endpoint echoes the grant it rejected, and the failure summary + // quotes that body. This is the silent-failure shape the whole change + // exists for: the error reaches a user-visible message AND the log, so + // assert against both renderings of the failure. + withTokenEndpoint( + () => + json(400, { + error: "invalid_grant", + error_description: "Refresh token is no longer valid", + refresh_token: ECHOED_REFRESH, + access_token: ECHOED_ACCESS, + }), + ({ tokenUrl }) => + Effect.gen(function* () { + // `Effect.flip` keeps the failure typed as `OAuth2Error` — the tagged + // error carries `message` and `error` as declared fields. + const error = yield* Effect.flip( + refreshAccessToken({ + tokenUrl, + clientId: "cid", + clientSecret: Redacted.make(SENT_SECRET), + refreshToken: Redacted.make(SENT_REFRESH), + }), + ); + // The diagnostic that makes the failure worth surfacing survives. + expect(error.error).toBe("invalid_grant"); + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuth2Error carries a typed `message`; the leak test asserts on its rendering + expect(error.message).toContain("Refresh token is no longer valid"); + + // The rendering a log actually emits: the error goes through the + // logger's own cause serialization, not a hand-rolled stringify. + const logged = yield* Effect.gen(function* () { + const lines = yield* Ref.make([]); + yield* Effect.logError("OAuth token refresh failed", error).pipe( + Effect.provide( + Logger.layer([ + Logger.make((options) => + Effect.runSync( + Ref.update(lines, (all) => [ + ...all, + `${options.message} ${String(options.cause)}`, + ]), + ), + ), + ]), + ), + ); + return (yield* Ref.get(lines)).join("\n"); + }); + + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuth2Error carries a typed `message`; the leak test asserts on its rendering + for (const rendering of [error.message, JSON.stringify(error), logged]) { + for (const secret of [ + SENT_REFRESH, + SENT_SECRET, + ECHOED_ACCESS, + ECHOED_REFRESH, + ] as const) { + expect(rendering).not.toContain(secret); + } + } + }), + ), + ); + + it.effect("redacting the cause keeps a transport failure's own diagnostics", () => + // A native fetch failure has NO enumerable own property — its name, + // message, and stack are all non-enumerable — so projecting the cause + // through a plain object walk silently reduces it to `{}`. Redaction must + // not cost the only diagnostic a connection-refused failure has. + Effect.gen(function* () { + const error = yield* Effect.flip( + exchangeAuthorizationCode({ + tokenUrl: "http://127.0.0.1:1/token", + clientId: "cid", + redirectUrl: "https://cb", + codeVerifier: "v", + code: "c", + timeoutMs: 100, + }), + ); + expect(Object.keys(error.cause as object)).toEqual( + expect.arrayContaining(["name", "message"]), + ); + }), + ); +}); + describe("shouldRefreshToken", () => { it("never refreshes when expiresAt is null", () => { expect(shouldRefreshToken({ expiresAt: null })).toBe(false); diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index daf5892cd..d2d156bb0 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -16,9 +16,11 @@ // construction keeps the call sync and lets callers opt out of PAR // --------------------------------------------------------------------------- -import { Data, Effect, Option, Predicate, Schema } from "effect"; +import { Data, Effect, Option, Predicate, Redacted, Schema } from "effect"; import * as oauth from "oauth4webapi"; +import { endpointForTelemetry } from "./telemetry-endpoint"; + // --------------------------------------------------------------------------- // Errors // --------------------------------------------------------------------------- @@ -39,10 +41,26 @@ export class OAuth2Error extends Data.TaggedError("OAuth2Error")<{ // Token response shape (RFC 6749 §5.1) // --------------------------------------------------------------------------- +/** Token material a caller hands one of these helpers. Widened to accept a bare + * string as well as `Redacted` so a value that never left the process (a code + * straight off the callback query string, a pasted secret) does not have to be + * wrapped first. Outputs are always `Redacted` — the guarantee lives there. */ +export type OAuth2SecretInput = string | Redacted.Redacted; + +/** Unwrap at an allowlisted boundary: `oauth4webapi` takes bare strings and puts + * them on the wire. Every call site is a form field or an HTTP header. */ +const secretToSend = (value: OAuth2SecretInput): string => + // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: `oauth4webapi` takes bare strings; every call site is a form field or an HTTP header + Redacted.isRedacted(value) ? Redacted.value(value) : value; + export type OAuth2TokenResponse = { - readonly access_token: string; + /** Wrapped at construction (`tokenResponseFrom`) so the minted token cannot + * reach a log, a span attribute, or an error payload without an explicit + * `Redacted.value`. Unwrap only at a persistence or wire line — a missed + * unwrap does not throw, it writes the literal "". */ + readonly access_token: Redacted.Redacted; readonly token_type?: string; - readonly refresh_token?: string; + readonly refresh_token?: Redacted.Redacted; readonly expires_in?: number; readonly scope?: string; readonly idTokenIdentityLabel?: string; @@ -103,14 +121,22 @@ export const assertSupportedOAuthEndpointUrl = ( // PKCE (RFC 7636) — straight delegation to `oauth4webapi` // --------------------------------------------------------------------------- -export const createPkceCodeVerifier = (): string => oauth.generateRandomCodeVerifier(); +/** The PKCE verifier is grant material: whoever holds it plus the authorization + * code can redeem the token. Wrapped for its whole in-memory life; the only + * unwraps are the challenge computation below and the session-row write. */ +export const createPkceCodeVerifier = (): Redacted.Redacted => + Redacted.make(oauth.generateRandomCodeVerifier()); -export const createPkceCodeChallenge = (verifier: string): Promise => - oauth.calculatePKCECodeChallenge(verifier); +/** The challenge is a public S256 hash of the verifier (it travels in the + * authorize URL), so it comes back bare. */ +export const createPkceCodeChallenge = (verifier: OAuth2SecretInput): Promise => + oauth.calculatePKCECodeChallenge(secretToSend(verifier)); /** RFC 6749 `state` — an unguessable correlation token minted by `oauth.start` - * and redeemed by `oauth.complete`. */ -export const createOAuthState = (): string => oauth.generateRandomState(); + * and redeemed by `oauth.complete`. Wrapped like the verifier: it is the + * session's bearer key, and it is persisted and echoed through the provider. */ +export const createOAuthState = (): Redacted.Redacted => + Redacted.make(oauth.generateRandomState()); // --------------------------------------------------------------------------- // Authorization URL builder @@ -286,21 +312,96 @@ const responseFromOAuthErrorCause = (cause: unknown): Response | undefined => { return undefined; }; +// Every credential a token endpoint can echo back, in both wire encodings it +// can echo them in: JSON and form-urlencoded. `code`, `code_verifier`, +// `device_code`, and the `*assertion` family are grant material — a leaked one +// is redeemable, exactly like a token. `client_assertion_type` and +// `code_challenge` are deliberately absent: the first is a public URN, the +// second is a hash of a verifier that is itself redacted. +const CREDENTIAL_BODY_KEYS = + "access_token|refresh_token|id_token|client_secret|code_verifier|assertion|client_assertion|device_code"; + const redactTokenEndpointBody = (body: string): string => body .replaceAll( - /("(?:access_token|refresh_token|id_token|client_secret)"\s*:\s*")[^"]*(")/gi, + new RegExp(`("(?:${CREDENTIAL_BODY_KEYS})"\\s*:\\s*")[^"]*(")`, "gi"), "$1[redacted]$2", ) + // A JSON `code` is an authorization code EXCEPT inside an RFC 6749 §5.2 + // error object, where providers spell their error label + // `{"error":{"code":"invalid_client_id"}}`. That label is the diagnostic + // point of the summary, so it is the one exemption — matching the `"error":{` + // prefix keeps the match whole and unreplaced; a `code` anywhere else is + // redacted. .replaceAll( - /((?:access_token|refresh_token|id_token|client_secret|code)=)[^&\s]*/gi, - "$1[redacted]", - ); + /("error"\s*:\s*\{\s*)?("code"\s*:\s*")[^"]*(")/gi, + (match, errorPrefix, key, close) => + errorPrefix === undefined ? `${key}[redacted]${close}` : match, + ) + .replaceAll(new RegExp(`((?:${CREDENTIAL_BODY_KEYS}|code)=)[^&\\s]*`, "gi"), "$1[redacted]"); + +const CREDENTIAL_KEY_PATTERN = new RegExp(`^(?:${CREDENTIAL_BODY_KEYS})$`, "i"); + +/** The cause an `OAuth2Error` carries is the raw failure oauth4webapi threw, and + * for an RFC 6749 §5.2 rejection that object holds the endpoint's PARSED error + * body — which echoes the grant it rejected. The `message` is summarized + * through `redactTokenEndpointBody`, but the cause is not, and anything that + * serializes the failure (a log line, an error payload, `JSON.stringify` of the + * Effect cause) republishes those credentials verbatim. + * + * So the cause is projected into a plain object with every credential-named + * leaf replaced, keeping the diagnostics (`error`, `error_description`, + * `status`, `name`) that make it worth attaching. `Response` instances pass + * through untouched: their body is not enumerable, and the summary already + * quoted a redacted preview of it. */ +const redactErrorCause = ( + cause: unknown, + /** True when `cause` is the value of an `error` key, where a `code` entry is + * the provider's error LABEL (`{"error":{"code":"invalid_client_id"}}`) and + * not an authorization code. Same exemption `redactTokenEndpointBody` makes + * for the text form, spelled structurally. */ + insideErrorObject = false, + seen: ReadonlySet = new Set(), +): unknown => { + if (cause instanceof Response) return cause; + if (seen.has(cause as object)) return "[circular]"; + if (Array.isArray(cause)) { + const next = new Set([...seen, cause]); + return cause.map((entry) => redactErrorCause(entry, false, next)); + } + if (!Predicate.isObject(cause)) return cause; + const next = new Set([...seen, cause]); + const redacted = Object.fromEntries( + Object.entries(cause).map(([key, value]) => [ + key, + CREDENTIAL_KEY_PATTERN.test(key) || (key === "code" && !insideErrorObject) + ? "[redacted]" + : redactErrorCause(value, key === "error", next), + ]), + ); + // oxlint-disable-next-line executor/no-instanceof-error -- boundary: `cause` is the untyped value oauth4webapi threw; narrowing it is how the diagnostics below are recovered + if (!(cause instanceof Error)) return redacted; + // `name`, `message`, and `stack` are non-enumerable on an Error, so the + // projection above drops them — and for a transport failure they are the ONLY + // diagnostic (a native fetch TypeError has no enumerable own property at + // all). Carry them explicitly, with the message scrubbed the same way the + // HTTP summary scrubs a body: a library that quotes a rejected field in its + // message must not smuggle the value back in through the cause. + return { + ...redacted, + name: cause.name, + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: narrowed to Error above; the message is scrubbed, not interpreted + message: redactTokenEndpointBody(cause.message), + ...(cause.stack === undefined ? {} : { stack: cause.stack }), + }; +}; const tokenEndpointHttpSummary = async (response: Response): Promise => { const status = `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`; const contentType = response.headers.get("content-type"); - const url = response.url ? ` from ${response.url}` : ""; + // The token endpoint is configured per OAuth client and can carry credentials + // in its query string; this summary lands in a user-visible error message. + const url = response.url ? ` from ${endpointForTelemetry(response.url)}` : ""; const parts = [`${status}${url}`]; if (contentType) parts.push(`content-type ${contentType}`); const preview = await bodyPreviewFromResponse(response); @@ -338,7 +439,7 @@ const toOAuth2Error = (cause: unknown): OAuth2Error => { return new OAuth2Error({ message: `OAuth token exchange failed: ${description ?? code ?? "unknown error"}`, error: code, - cause, + cause: redactErrorCause(cause), }); } return new OAuth2Error({ @@ -358,7 +459,7 @@ const toOAuth2ErrorWithHttpSummary = (cause: unknown): Effect.Effect { - if (!clientSecret) return oauth.None(); - return method === "basic" - ? oauth.ClientSecretBasic(clientSecret) - : oauth.ClientSecretPost(clientSecret); + if (clientSecret == null) return oauth.None(); + // Boundary: oauth4webapi puts the secret in the form body / Basic header. + const secret = secretToSend(clientSecret); + return method === "basic" ? oauth.ClientSecretBasic(secret) : oauth.ClientSecretPost(secret); }; const tokenResponseFrom = (r: oauth.TokenEndpointResponse): OAuth2TokenResponse => ({ - access_token: r.access_token, + access_token: Redacted.make(r.access_token), token_type: r.token_type, - refresh_token: r.refresh_token, + // Presence is `undefined`, never falsiness. oauth4webapi has already rejected + // an empty `refresh_token` by this point. + refresh_token: r.refresh_token === undefined ? undefined : Redacted.make(r.refresh_token), expires_in: typeof r.expires_in === "number" ? r.expires_in : undefined, scope: r.scope, }); @@ -556,10 +660,10 @@ export type ExchangeAuthorizationCodeInput = { readonly tokenUrl: string; readonly issuerUrl?: string | null; readonly clientId: string; - readonly clientSecret?: string | null; + readonly clientSecret?: OAuth2SecretInput | null; readonly redirectUrl: string; - readonly codeVerifier: string; - readonly code: string; + readonly codeVerifier: OAuth2SecretInput; + readonly code: OAuth2SecretInput; readonly clientAuth?: ClientAuthMethod; readonly idTokenSigningAlgValuesSupported?: readonly string[]; /** RFC 8707 Resource Indicator. MCP Auth spec MUST-requires this on @@ -590,10 +694,11 @@ export const exchangeAuthorizationCode = ( // takes the `code` directly (the UI already validated `state` by // looking up the session), so skip the library's state-validation // rail and go through the generic grant request instead. + // Boundary: both go out as RFC 6749 form fields. const params = new URLSearchParams({ - code: input.code, + code: secretToSend(input.code), redirect_uri: input.redirectUrl, - code_verifier: input.codeVerifier, + code_verifier: secretToSend(input.codeVerifier), }); if (input.resource) { params.set("resource", input.resource); @@ -623,7 +728,9 @@ export const exchangeAuthorizationCode = ( export type ExchangeClientCredentialsInput = { readonly tokenUrl: string; readonly clientId: string; - readonly clientSecret: string; + /** Null for a client the AS registered without a secret; the grant then goes + * out unauthenticated and the AS decides. Never `""` — see `OAuthClient`. */ + readonly clientSecret: OAuth2SecretInput | null; readonly scopes?: readonly string[]; readonly scopeSeparator?: string; readonly clientAuth?: ClientAuthMethod; @@ -679,8 +786,8 @@ export type RefreshAccessTokenInput = { readonly tokenUrl: string; readonly issuerUrl?: string | null; readonly clientId: string; - readonly clientSecret?: string | null; - readonly refreshToken: string; + readonly clientSecret?: OAuth2SecretInput | null; + readonly refreshToken: OAuth2SecretInput; readonly scopes?: readonly string[]; readonly scopeSeparator?: string; readonly clientAuth?: ClientAuthMethod; @@ -721,7 +828,8 @@ export const refreshAccessToken = ( as, client, clientAuth, - input.refreshToken, + // Boundary: goes out as the `refresh_token` form field. + secretToSend(input.refreshToken), { ...oauth4webapiRequestOptions( input.tokenUrl, diff --git a/packages/core/sdk/src/oauth-list-clients.test.ts b/packages/core/sdk/src/oauth-list-clients.test.ts index 086513ce3..61c18f260 100644 --- a/packages/core/sdk/src/oauth-list-clients.test.ts +++ b/packages/core/sdk/src/oauth-list-clients.test.ts @@ -169,7 +169,7 @@ describe("oauth.listClients", () => { tokenUrl: "https://mcp.axiom.co/token", grant: "authorization_code", clientId: "old-dcr-client", - clientSecret: "", + clientSecret: null, resource: "https://mcp.axiom.co/mcp", origin: { kind: "manual" }, }); diff --git a/packages/core/sdk/src/oauth-register-dynamic.test.ts b/packages/core/sdk/src/oauth-register-dynamic.test.ts index 266c7cc4d..16b808634 100644 --- a/packages/core/sdk/src/oauth-register-dynamic.test.ts +++ b/packages/core/sdk/src/oauth-register-dynamic.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Predicate } from "effect"; +import { Effect, Predicate, Redacted } from "effect"; import { AuthTemplateSlug, @@ -44,7 +44,10 @@ const oauthPlugin = definePlugin(() => ({ Effect.succeed({ tools: [{ name: ToolName.make("whoami"), description: "whoami" }], }), - invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }), + invokeTool: ({ credential }) => + Effect.succeed({ + token: credential.value === null ? null : Redacted.value(credential.value), + }), extension: (ctx) => ({ seed: () => ctx.core.integrations.register({ @@ -274,7 +277,7 @@ describe("oauth.registerDynamicClient", () => { resource: server.mcpResourceUrl, grant: "authorization_code", clientId: "legacy-dcr-client", - clientSecret: "", + clientSecret: null, }); yield* Effect.promise(() => config.db.updateMany("oauth_client", { @@ -327,7 +330,7 @@ describe("oauth.registerDynamicClient", () => { resource: server.mcpResourceUrl, grant: "authorization_code", clientId: "legacy-dcr-client", - clientSecret: "", + clientSecret: null, }); // Simulate the migration's backfill: legacy DCR stamp + issuer set. yield* Effect.promise(() => diff --git a/packages/core/sdk/src/oauth-scope-union.test.ts b/packages/core/sdk/src/oauth-scope-union.test.ts index 744e6f941..a01b14f35 100644 --- a/packages/core/sdk/src/oauth-scope-union.test.ts +++ b/packages/core/sdk/src/oauth-scope-union.test.ts @@ -1,6 +1,6 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import { describe, expect, it } from "@effect/vitest"; -import { Context, Effect, Exit, Layer, Predicate, type Scope } from "effect"; +import { Context, Effect, Exit, Layer, Predicate, Redacted, type Scope } from "effect"; import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { @@ -46,7 +46,10 @@ const makeScopePluginWithId = ( Effect.succeed({ tools: [{ name: ToolName.make("whoami"), description: "whoami" }], }), - invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }), + invokeTool: ({ credential }) => + Effect.succeed({ + token: credential.value === null ? null : Redacted.value(credential.value), + }), describeAuthMethods: (record: IntegrationRecord): readonly AuthMethodDescriptor[] => { const cfg = record.config as { readonly scopes?: readonly string[] | null } | null; const scopes = cfg?.scopes; diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 65227a0f9..443c4d833 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -14,7 +14,7 @@ // redeems the session, exchanges the code, and mints the connection. // --------------------------------------------------------------------------- -import { Duration, Effect, Layer, Option, Schema } from "effect"; +import { Duration, Effect, Layer, Option, Redacted, Schema } from "effect"; import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; import type { Connection } from "./connection"; @@ -35,6 +35,7 @@ import { OAuthRegisterDynamicError, OAuthSessionNotFoundError, OAuthStartError, + oauthClientSecretFromInput, type ConnectResult, type CreateOAuthClientInput, type OAuthClientOrigin, @@ -72,6 +73,7 @@ import { } from "./oauth-helpers"; import { OAUTH2_SESSION_TTL_MS, encodeOAuthCallbackState } from "./oauth"; import { canonicalIssuerUrl, hostOfUrl, isDcrClassifiedRow, parseUrl } from "./oauth-gc"; +import { endpointForTelemetry } from "./telemetry-endpoint"; /** Connection-minting input for the OAuth flow — extends a connection create * with the OAuth lifecycle fields (client slug, refresh material, expiry, @@ -200,7 +202,9 @@ const recordedOAuthScope = ( const granted = token.scope.split(/\s+/).filter(Boolean); const coveredByRefreshToken = - token.refresh_token && requestedScopes.includes("offline_access") ? ["offline_access"] : []; + token.refresh_token !== undefined && requestedScopes.includes("offline_access") + ? ["offline_access"] + : []; const recorded = dedupeScopes([...granted, ...coveredByRefreshToken]); return recorded.join(" ") || null; }; @@ -386,8 +390,9 @@ interface LoadedOAuthClient { readonly tokenUrl: string; readonly grant: OAuthGrant; readonly clientId: string; - /** Resolved literal secret (read from the provider via the stored item id). */ - readonly clientSecret: string; + /** Resolved secret (read from the provider via the stored item id), or null + * for a public / PKCE client that stored none. */ + readonly clientSecret: Redacted.Redacted | null; readonly resource: string | null; } @@ -595,11 +600,37 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { }); const now = new Date(); + // The empty string is not a spelling of "public client" here — presence is + // null. Accepting it silently would register a CONFIDENTIAL app whose + // secret is empty (the token request would then send `client_secret=`), + // so reject it and make the caller state which it meant. Boundaries that + // can only produce "" (HTTP payloads, form fields) normalize through + // `oauthClientSecretFromInput` before calling. Tested on the unwrapped + // value: a wrapped "" is just as empty and just as wrong. + // + // Reported as `StorageError` because that is `createClient`'s declared + // failure type, which the OAuthService contract and every caller are + // typed against; a dedicated validation error would ripple through the + // published contract and belongs in its own change. + if ( + input.clientSecret != null && + (Redacted.isRedacted(input.clientSecret) + ? // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: emptiness check only; the wrapper cannot answer it + Redacted.value(input.clientSecret) + : input.clientSecret) === "" + ) { + return yield* new StorageError({ + message: + "Invalid OAuth client secret: pass null for a public/PKCE client, or a non-empty secret for a confidential one.", + cause: undefined, + }); + } + // Store the secret out-of-band in the default writable provider; the row - // keeps only its item id. A public/PKCE client (empty secret) stores null + // keeps only its item id. A public/PKCE client (null secret) stores null // — there is no plaintext column to fall back to (the schema dropped it). let clientSecretItemIdValue: string | null = null; - if (input.clientSecret.length > 0) { + if (input.clientSecret !== null) { const provider = deps.defaultWritableProvider(); if (!provider || !provider.set) { return yield* new StorageError({ @@ -910,7 +941,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { ); // Persist the minted client. DCR-minted public clients have no secret; we - // store "" so the PKCE-only token exchange omits `client_secret`. + // store null so the PKCE-only token exchange omits `client_secret`. // Confidential DCR clients keep the returned secret in the credential // provider. The persisted grant is interactive authorization_code. // `input.scopes` was already sent to the AS at registration above; the @@ -923,7 +954,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { resource: input.resource ?? null, grant: "authorization_code", clientId: information.client_id, - clientSecret: information.client_secret ?? "", + clientSecret: oauthClientSecretFromInput(information.client_secret), origin: { kind: "dynamic_client_registration", integration: input.originIntegration ?? null, @@ -1000,17 +1031,29 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { ); } // `client_secret_item_id` is null for DCR-minted / public PKCE clients; - // the token exchange treats a missing secret as "public client, omit + // the token exchange treats a null secret as "public client, omit // client_secret" (see pickClientAuth). A confidential client persisted // its secret to the provider in createClient; resolve it back here. + // An unresolvable item id also lands on null — the row says the app is + // confidential but the vault no longer holds the value, so the grant + // goes out unauthenticated and the AS rejects it, which is the visible + // failure a re-registration prompt hangs off. return Effect.gen(function* () { - let clientSecret = ""; + let clientSecret: Redacted.Redacted | null = null; if (row.client_secret_item_id != null) { const provider = deps.defaultWritableProvider(); if (provider) { + // The secret stays wrapped from here to the token endpoint; the + // helpers unwrap it at the oauth4webapi call. + const stored = yield* provider.get( + ProviderItemId.make(String(row.client_secret_item_id)), + ); clientSecret = - (yield* provider.get(ProviderItemId.make(String(row.client_secret_item_id)))) ?? - ""; + stored !== null && + // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: emptiness check only; the wrapper is what is assigned + oauthClientSecretFromInput(Redacted.value(stored)) !== null + ? stored + : null; } } return { @@ -1145,9 +1188,17 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { : yield* filterAuthorizationCodeScopes(client, requestedScopes); // authorization_code: persist a session + build the authorize URL. + // The verifier stays wrapped across the challenge computation and down to + // the session write below; the challenge itself is a public S256 hash. const verifier = createPkceCodeVerifier(); const challenge = yield* Effect.promise(() => createPkceCodeChallenge(verifier)); - const state = OAuthState.make(createOAuthState()); + // Boundary: `OAuthState` is a correlation KEY, not just grant material — + // it is matched by equality in `oauth_session`, echoed to the provider in + // the authorize URL, and returned to the caller so `complete` can quote + // it. It cannot stay wrapped, so the unwrap happens here at the branded-id + // constructor rather than being spread across those call sites. + // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: `OAuthState` is a correlation key matched by equality and echoed in the authorize URL + const state = OAuthState.make(Redacted.value(createOAuthState())); const providerState = encodeOAuthCallbackState({ state: String(state), orgSlug: deps.callbackStateOrgSlug, @@ -1166,7 +1217,12 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { name: String(input.name), template: String(input.template), redirect_url: flowRedirectUri, - pkce_verifier: verifier, + // Boundary: the DB persistence line. `Redacted`'s toJSON renders + // "", so a missed unwrap here would silently store that + // literal and every later `complete` would fail PKCE validation. + // (The column itself is plaintext; encrypting it is a separate change.) + // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the `oauth_session.pkce_verifier` persistence line + pkce_verifier: Redacted.value(verifier), identity_label: input.identityLabel ?? null, // Persist the requested scope set (declared ∪ client, filtered to the // authorization-code flow) so `complete`'s recorded-scope fallback @@ -1230,7 +1286,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { name: ConnectionName.make(String(sessionRow.name)), template: AuthTemplateSlug.make(String(sessionRow.template)), redirectUrl: String(sessionRow.redirect_url), - pkceVerifier: sessionRow.pkce_verifier == null ? null : String(sessionRow.pkce_verifier), + // Re-wrapped on the way out of storage: everything downstream treats the + // verifier as grant material again, and the exchange unwraps it. + pkceVerifier: + sessionRow.pkce_verifier == null ? null : Redacted.make(String(sessionRow.pkce_verifier)), identityLabel: sessionRow.identity_label == null ? null : String(sessionRow.identity_label), expiresAt: Number(sessionRow.expires_at), // The scope set `start` requested (the integration's declared or @@ -1368,11 +1427,13 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { cause: undefined, }); } + // `provider.set` accepts `Redacted` and unwraps at its own write line, so + // the token never becomes a bare string here. const itemId = accessItemId(target.owner, target.integration, target.name); yield* provider.set(ProviderItemId.make(itemId), token.access_token); let refreshItemId: string | null = null; - if (token.refresh_token) { + if (token.refresh_token !== undefined) { refreshItemId = refreshItemIdFor(itemId); yield* provider.set(ProviderItemId.make(refreshItemId), token.refresh_token); } @@ -1444,8 +1505,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { ), ); if (!as) { + // `input.url` is a raw user paste and a first-class credential carrier + // (an MCP endpoint with `?token=…`, or `user:pass@host`). This message + // reaches the UI and the logs, so echo only the sanitized endpoint. return yield* new OAuthProbeError({ - message: `No OAuth authorization-server metadata found at ${input.url}`, + message: `No OAuth authorization-server metadata found at ${endpointForTelemetry(input.url)}`, }); } return { diff --git a/packages/core/sdk/src/plugin.ts b/packages/core/sdk/src/plugin.ts index 6d4dbc23b..207a472ac 100644 --- a/packages/core/sdk/src/plugin.ts +++ b/packages/core/sdk/src/plugin.ts @@ -1,5 +1,5 @@ import { Effect, type Schema as EffectSchema } from "effect"; -import type { Context, Layer } from "effect"; +import type { Context, Layer, Redacted } from "effect"; import type { HttpClient } from "effect/unstable/http"; import type { HttpApiGroup } from "effect/unstable/httpapi"; import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"; @@ -238,8 +238,12 @@ export interface PluginCtx { * tool — where an inline `refresh` would block the caller. */ readonly markToolsStale: (ref: ConnectionRef) => Effect.Effect; /** Resolve a connection's value through its provider (and OAuth refresh). - * null if the provider can't produce one. */ - readonly resolveValue: (ref: ConnectionRef) => Effect.Effect; + * null if the provider can't produce one. `Redacted` so the value cannot + * reach a log or a serialized payload without an explicit unwrap at the + * point it goes on the wire. */ + readonly resolveValue: ( + ref: ConnectionRef, + ) => Effect.Effect | null, StorageFailure>; }; /** Registered credential backends — for discovery (browse a backend's items). */ @@ -249,11 +253,12 @@ export interface PluginCtx { provider: ProviderKey, ) => Effect.Effect; /** Read an opaque item from a provider. Plugins use this for secret values - * they own that are not modeled as connections. */ + * they own that are not modeled as connections. `Redacted` so a plugin + * cannot log or serialize the value without saying so. */ readonly get: ( provider: ProviderKey, id: ProviderItemId, - ) => Effect.Effect; + ) => Effect.Effect | null, StorageFailure>; readonly has: ( provider: ProviderKey, id: ProviderItemId, @@ -262,7 +267,7 @@ export interface PluginCtx { * provider key that owns the item. */ readonly setDefault: ( id: ProviderItemId, - value: string, + value: string | Redacted.Redacted, ) => Effect.Effect; readonly remove: ( provider: ProviderKey, @@ -314,11 +319,14 @@ export interface ResolveToolsInput { readonly template: AuthTemplateSlug | null; /** Lazily resolve the connection's credential value via its provider — only * the kinds that actually call out (mcp) pay for it. */ - readonly getValue: () => Effect.Effect; + readonly getValue: () => Effect.Effect | null, StorageFailure>; /** Lazily resolve every credential input (`variable → value`) — the * multi-input analog of `getValue`, for methods whose placements reference * more than one variable. Empty map when the connection isn't persisted. */ - readonly getValues: () => Effect.Effect, StorageFailure>; + readonly getValues: () => Effect.Effect< + Record | null>, + StorageFailure + >; } export interface ResolveToolsResult { @@ -361,12 +369,18 @@ export interface ToolInvocationCredential { readonly template: AuthTemplateSlug; /** The primary (`token`) resolved value — for OAuth (the access token) and * single-input apiKey methods. Equals `values.token`. */ - readonly value: string | null; + readonly value: Redacted.Redacted | null; /** Every resolved credential input (`variable → value`) for the connection. * Single-input methods have just `{ token }`; an apiKey method with two * distinct inputs (e.g. Datadog) has one entry per template variable. The - * render layer substitutes each `variable("")` from this map. */ - readonly values: Record; + * render layer substitutes each `variable("")` from this map. + * + * `Redacted` all the way to the render layer: this whole record is routinely + * stringified into pool keys, span attributes, and error payloads, and a + * wrapper renders "" in every one of them. Unwrap only where the + * value goes on the wire — `renderPlacementValue` in + * `@executor-js/sdk/http-auth` is that boundary for HTTP plugins. */ + readonly values: Record | null>; /** The integration's stored config, for template rendering. */ readonly config: IntegrationConfig; /** The OAuth scopes the connection's grant actually covers, from the diff --git a/packages/core/sdk/src/policies.test.ts b/packages/core/sdk/src/policies.test.ts index beb9703c4..869c01aa5 100644 --- a/packages/core/sdk/src/policies.test.ts +++ b/packages/core/sdk/src/policies.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Predicate, Result, Schema } from "effect"; +import { Effect, Predicate, Redacted, Result, Schema } from "effect"; import { type ToolPolicyRow } from "./core-schema"; import { @@ -20,7 +20,7 @@ import { resolveToolPolicy, } from "./policies"; import { definePlugin, tool } from "./plugin"; -import type { CredentialProvider } from "./provider"; +import { credentialValueToWrite, type CredentialProvider } from "./provider"; import { makeTestExecutor } from "./testing"; // --------------------------------------------------------------------------- @@ -270,8 +270,13 @@ const memoryProvider = (): CredentialProvider => { return { key: ProviderKey.make("memory"), writable: true, - get: (id) => Effect.sync(() => store.get(String(id)) ?? null), - set: (id, value) => Effect.sync(() => void store.set(String(id), value)), + get: (id) => + Effect.sync(() => { + const value = store.get(String(id)); + return value === undefined ? null : Redacted.make(value); + }), + set: (id, value) => + Effect.sync(() => void store.set(String(id), credentialValueToWrite(value))), }; }; diff --git a/packages/core/sdk/src/promise.test.ts b/packages/core/sdk/src/promise.test.ts index 8592a6093..3159a3167 100644 --- a/packages/core/sdk/src/promise.test.ts +++ b/packages/core/sdk/src/promise.test.ts @@ -1,10 +1,16 @@ import { describe, expect, it } from "@effect/vitest"; -import { createExecutor, ProviderItemId, ProviderKey, type CredentialProvider } from "./promise"; +import { + createExecutor, + credentialValueToWrite, + ProviderItemId, + ProviderKey, + type CredentialProvider, +} from "./promise"; import { definePlugin, tool } from "./plugin"; import type { ToolDef } from "./tool"; import { IntegrationSlug, ToolName } from "./ids"; -import { Effect, Schema } from "effect"; +import { Effect, Redacted, Schema } from "effect"; // A minimal static-tool plugin built on the Effect surface, consumed // through the Promise façade. Exercises the proxy's ability to promisify @@ -148,10 +154,14 @@ describe("promise/createExecutor", () => { const memoryProvider: CredentialProvider = { key: ProviderKey.make("memory"), writable: true, - get: (id: ProviderItemId) => Effect.sync(() => store.get(String(id)) ?? null), - set: (id: ProviderItemId, value: string) => + get: (id: ProviderItemId) => Effect.sync(() => { - store.set(String(id), value); + const value = store.get(String(id)); + return value === undefined ? null : Redacted.make(value); + }), + set: (id: ProviderItemId, value: string | Redacted.Redacted) => + Effect.sync(() => { + store.set(String(id), credentialValueToWrite(value)); }), }; diff --git a/packages/core/sdk/src/promise.ts b/packages/core/sdk/src/promise.ts index d48d10607..a4461c3c8 100644 --- a/packages/core/sdk/src/promise.ts +++ b/packages/core/sdk/src/promise.ts @@ -39,6 +39,7 @@ export type { // but Promise consumers still author them to register an inline writable store // via `createExecutor({ providers })`. export type { CredentialProvider, ProviderEntry } from "./provider"; +export { credentialValueToWrite } from "./provider"; export type { CreateToolPolicyInput, RemoveToolPolicyInput, diff --git a/packages/core/sdk/src/provider.test.ts b/packages/core/sdk/src/provider.test.ts new file mode 100644 index 000000000..dcd43d84d --- /dev/null +++ b/packages/core/sdk/src/provider.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Redacted } from "effect"; + +import { ProviderItemId, ProviderKey } from "./ids"; +import { credentialValueToWrite, type CredentialProvider } from "./provider"; +import { memoryCredentialsPlugin } from "./test-config"; + +// --------------------------------------------------------------------------- +// The chokepoint's own invariants. `Redacted` renders as the string +// "" through toString/toJSON instead of throwing, so a backend that +// forgets to unwrap on a WRITE persists that literal and every later read is +// self-consistently wrong. These cases pin the unwrap helper and the shipped +// in-memory provider that most of the suite writes credentials through. +// --------------------------------------------------------------------------- + +const memoryProvider = (): CredentialProvider => { + const plugin = memoryCredentialsPlugin(); + const providers = plugin.credentialProviders as readonly CredentialProvider[]; + return providers[0]!; +}; + +describe("credentialValueToWrite", () => { + it("unwraps a Redacted to the real secret", () => { + expect(credentialValueToWrite(Redacted.make("sk_wrapped_written"))).toBe("sk_wrapped_written"); + }); + + it("passes a bare string through unchanged", () => { + expect(credentialValueToWrite("sk_plain_written")).toBe("sk_plain_written"); + }); + + it("never yields the '' placeholder", () => { + expect(credentialValueToWrite(Redacted.make("sk_placeholder_check"))).not.toBe(""); + // How the placeholder would arrive: the unguarded stringification a naive + // implementation performs. + expect(String(Redacted.make("sk_placeholder_check"))).toContain("redacted"); + }); + + it("preserves an empty secret, which is a value and not an absence", () => { + expect(credentialValueToWrite(Redacted.make(""))).toBe(""); + }); +}); + +describe("memory credential provider", () => { + it.effect("stores the real secret for both string and Redacted input", () => + Effect.gen(function* () { + const provider = memoryProvider(); + + yield* provider.set!(ProviderItemId.make("plain"), "sk_plain_written"); + yield* provider.set!(ProviderItemId.make("wrapped"), Redacted.make("sk_wrapped_written")); + + const plain = yield* provider.get(ProviderItemId.make("plain")); + const wrapped = yield* provider.get(ProviderItemId.make("wrapped")); + + expect(plain === null ? null : Redacted.value(plain)).toBe("sk_plain_written"); + expect(wrapped === null ? null : Redacted.value(wrapped)).toBe("sk_wrapped_written"); + }), + ); + + it.effect("returns Redacted from get, and null for a missing id", () => + Effect.gen(function* () { + const provider = memoryProvider(); + yield* provider.set!(ProviderItemId.make("token"), "sk_round_trip"); + + expect(Redacted.isRedacted(yield* provider.get(ProviderItemId.make("token")))).toBe(true); + expect(yield* provider.get(ProviderItemId.make("absent"))).toBeNull(); + expect(provider.key).toBe(ProviderKey.make("memory")); + }), + ); + + it.effect("treats a stored empty value as present", () => + Effect.gen(function* () { + const provider = memoryProvider(); + yield* provider.set!(ProviderItemId.make("empty"), Redacted.make("")); + + const found = yield* provider.get(ProviderItemId.make("empty")); + expect(found).not.toBeNull(); + expect(found === null ? null : Redacted.value(found)).toBe(""); + }), + ); +}); diff --git a/packages/core/sdk/src/provider.ts b/packages/core/sdk/src/provider.ts index 42a3defa4..f4dc2ab88 100644 --- a/packages/core/sdk/src/provider.ts +++ b/packages/core/sdk/src/provider.ts @@ -1,3 +1,4 @@ +import { Redacted } from "effect"; import type { Effect } from "effect"; import type { StorageFailure } from "./fuma-runtime"; @@ -24,12 +25,39 @@ export interface CredentialProvider { * connection's `remove` only drops our routing, leaving the item intact. */ readonly writable: boolean; /** Resolve a value by opaque id. The single hop a credential goes through - * before its template is applied. The provider interprets the id. */ - readonly get: (id: ProviderItemId) => Effect.Effect; + * before its template is applied. The provider interprets the id. + * + * Returns `Redacted` so a credential cannot reach a log, a span attribute, or + * an error message by accident — this is the chokepoint the guarantee hangs + * off, so implementations must never widen it back to a bare string. + * + * Absence is `null`, and every caller in the chain tests it explicitly: + * `Redacted.make("")` is truthy, so a falsiness test would report a stored + * empty value as absent. This is the one place that fact is stated; the + * presence checks downstream are its consequence. */ + readonly get: ( + id: ProviderItemId, + ) => Effect.Effect | null, StorageFailure>; readonly has?: (id: ProviderItemId) => Effect.Effect; - readonly set?: (id: ProviderItemId, value: string) => Effect.Effect; + /** Accepts a bare string as well as `Redacted` so callers holding a value that + * never left the process (a freshly minted token, a pasted form field) do not + * have to wrap it first. Unwrap with `Redacted.value` at the serialization + * line: a missed unwrap serializes the literal "" and silently + * persists garbage. */ + readonly set?: ( + id: ProviderItemId, + value: string | Redacted.Redacted, + ) => Effect.Effect; readonly delete?: (id: ProviderItemId) => Effect.Effect; /** Browse entries for discovery (pick a 1Password item). Optional — some * backends can't enumerate. */ readonly list?: () => Effect.Effect; } + +/** The unwrap a `set` implementation performs at its serialization line. Keep + * the call adjacent to the write: `Redacted`'s toString/toJSON render + * "", so a value that reaches a backend still wrapped is persisted as + * that literal instead of failing. */ +export const credentialValueToWrite = (value: string | Redacted.Redacted): string => + // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: THE serialization line every provider backend's `set` writes through + Redacted.isRedacted(value) ? Redacted.value(value) : value; diff --git a/packages/core/sdk/src/redacted-credential.test.ts b/packages/core/sdk/src/redacted-credential.test.ts new file mode 100644 index 000000000..e65cec046 --- /dev/null +++ b/packages/core/sdk/src/redacted-credential.test.ts @@ -0,0 +1,90 @@ +// --------------------------------------------------------------------------- +// The plugin-contract half of the `Redacted` credential guarantee: the +// `ToolInvocationCredential` core hands a plugin must not expose its secret +// when serialized, which is what a log line, a span attribute, or an error +// payload does to it. +// +// The wire half — the secret still reaches the upstream request byte for byte — +// is asserted against a live server in +// `packages/plugins/openapi/src/sdk/redacted-credential.test.ts`. Both halves +// are needed: either one alone passes trivially for a broken implementation. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Redacted } from "effect"; + +import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolAddress, ToolName } from "./ids"; +import { definePlugin, type ToolInvocationCredential } from "./plugin"; +import { makeTestExecutor, memoryCredentialsPlugin } from "./test-config"; + +// Synthetic: not shaped like any real provider's credential. +const TOKEN_SECRET = "synthetic-token-value"; +const TEAM_SECRET = "synthetic-team-value"; + +const INTEG = IntegrationSlug.make("acme"); +const TEMPLATE = AuthTemplateSlug.make("apiKey"); + +const makeCapturingPlugin = () => { + let captured: ToolInvocationCredential | undefined; + const plugin = definePlugin(() => ({ + id: "capture" as const, + storage: () => ({}), + resolveTools: () => + Effect.succeed({ tools: [{ name: ToolName.make("ping"), description: "ping" }] }), + invokeTool: ({ credential }) => + Effect.sync(() => { + captured = credential; + return { ok: true }; + }), + extension: (ctx) => ({ + seed: () => ctx.core.integrations.register({ slug: INTEG, description: "Acme", config: {} }), + }), + }))(); + return { plugin, read: () => captured }; +}; + +describe("ToolInvocationCredential redaction", () => { + it.effect("serializing the credential exposes neither the primary nor a named value", () => + Effect.gen(function* () { + const { plugin, read } = makeCapturingPlugin(); + const executor = yield* makeTestExecutor({ + plugins: [memoryCredentialsPlugin(), plugin] as const, + }); + yield* executor.capture.seed(); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + values: { token: TOKEN_SECRET, team: TEAM_SECRET }, + }); + + yield* executor.execute(ToolAddress.make("tools.acme.org.main.ping"), {}); + + const credential = read(); + expect(credential).toBeDefined(); + if (!credential) return; + + // What a log line or a span attribute would produce. + const serialized = JSON.stringify(credential); + expect(serialized).not.toContain(TOKEN_SECRET); + expect(serialized).not.toContain(TEAM_SECRET); + expect(serialized).toContain(""); + + // `value` is a separate field from `values`, so it gets its own check — + // it would be the easy one to widen back to a bare string. + expect(Redacted.isRedacted(credential.value)).toBe(true); + for (const entry of Object.values(credential.values)) { + expect(entry === null || Redacted.isRedacted(entry)).toBe(true); + } + + // …and the wrappers still hold the real secrets, so the assertions above + // are redaction rather than an empty credential. + expect(credential.value === null ? null : Redacted.value(credential.value)).toBe( + TOKEN_SECRET, + ); + const team = credential.values["team"]; + expect(team === null || team === undefined ? null : Redacted.value(team)).toBe(TEAM_SECRET); + }), + ); +}); diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index e78b56577..3b4c2d74c 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -46,6 +46,7 @@ export type { ValidateConnectionInput, } from "./connection"; export type { CredentialProvider, ProviderEntry } from "./provider"; +export { credentialValueToWrite } from "./provider"; export type { Tool, ToolDef, ToolListFilter, ToolAnnotations } from "./tool"; // Tagged errors (Schema-based — browser-safe). @@ -146,6 +147,7 @@ export { OAuthProbeError, OAuthRegisterDynamicError, OAuthSessionNotFoundError, + oauthClientSecretFromInput, } from "./oauth-client"; // Wire-level HTTP error schema for plugin HttpApiGroup definitions. @@ -177,3 +179,22 @@ export { type OAuthPopupResult, isOAuthPopupResult, } from "./oauth-popup-types"; + +// Span-value redaction. Browser-safe on purpose: the web client's OTLP +// exporter and the Workers isolates' OTel span processor apply the SAME +// policy, so it cannot live in either isolate. +export { + MAX_SPAN_TEXT_CHARS, + SPAN_QUERY_ATTRIBUTE, + SPAN_URL_ATTRIBUTES, + STRIPPED_QUERY_ATTRIBUTE, + redactQuery, + redactSensitiveKeyValuesInText, + redactSpanUrlAttribute, + redactSpanUrlAttributes, + redactUrl, + redactUrlQueryInText, + scrubSpanText, + truncateSpanText, + type SpanRedaction, +} from "./span-redaction"; diff --git a/packages/core/sdk/src/span-redaction.test.ts b/packages/core/sdk/src/span-redaction.test.ts new file mode 100644 index 000000000..e6804ab0d --- /dev/null +++ b/packages/core/sdk/src/span-redaction.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + MAX_SPAN_TEXT_CHARS, + STRIPPED_QUERY_ATTRIBUTE, + redactSensitiveKeyValuesInText, + redactSpanUrlAttribute, + redactSpanUrlAttributes, + redactUrlQueryInText, + scrubSpanText, + truncateSpanText, +} from "./span-redaction"; + +// Synthetic placeholders only — never a real authorization code or state. +const CODE = "synthetic-authorization-code"; +const STATE = "synthetic-csrf-state"; +const TOKEN = "synthetic-endpoint-token"; + +describe("redactSpanUrlAttributes", () => { + it("strips the authorization code and state from url.full and url.query", () => { + const attributes: Record = { + "url.full": `https://app.test/api/oauth/callback?code=${CODE}&state=${STATE}&domain=example.test`, + "url.query": `code=${CODE}&state=${STATE}&domain=example.test`, + "url.path": "/api/oauth/callback", + }; + + expect(redactSpanUrlAttributes(attributes)).toEqual(["code", "state"]); + expect(JSON.stringify(attributes)).not.toContain(CODE); + expect(JSON.stringify(attributes)).not.toContain(STATE); + // Route-level visibility is preserved. + expect(attributes["url.path"]).toBe("/api/oauth/callback"); + expect(attributes["url.full"]).toBe("https://app.test/api/oauth/callback?domain=example.test"); + }); + + it("strips a code nested inside the login redirect's returnTo parameter", () => { + const returnTo = encodeURIComponent(`/api/oauth/callback?code=${CODE}&state=${STATE}`); + const attributes: Record = { + "url.full": `https://app.test/login?returnTo=${returnTo}`, + }; + + expect(redactSpanUrlAttributes(attributes)).toEqual(["returnTo.code", "returnTo.state"]); + expect(JSON.stringify(attributes)).not.toContain(CODE); + expect(String(attributes["url.full"])).toContain("%2Fapi%2Foauth%2Fcallback"); + }); + + it("leaves a span with no sensitive parameters untouched", () => { + const attributes: Record = { "url.query": "owner=org" }; + expect(redactSpanUrlAttributes(attributes)).toEqual([]); + expect(attributes["url.query"]).toBe("owner=org"); + }); +}); + +describe("redactSpanUrlAttribute", () => { + it("returns null for an attribute that is not URL-bearing", () => { + expect(redactSpanUrlAttribute("db.statement", `select ${CODE}`)).toBeNull(); + expect(redactSpanUrlAttribute(STRIPPED_QUERY_ATTRIBUTE, "code")).toBeNull(); + }); + + it("returns null for a URL attribute carrying nothing sensitive", () => { + expect(redactSpanUrlAttribute("url.full", "https://app.test/api?owner=org")).toBeNull(); + }); + + it("returns the scrubbed value and the stripped keys", () => { + expect(redactSpanUrlAttribute("url.full", `https://mcp.test/mcp?token=${TOKEN}`)).toEqual({ + value: "https://mcp.test/mcp", + stripped: ["token"], + }); + }); + + it("ignores a non-string value", () => { + expect(redactSpanUrlAttribute("url.full", 42)).toBeNull(); + }); + + it("scrubs a relative url.full, keeping the path readable", () => { + // A `url.full` stamped from a relative request has no origin for `URL` to + // parse, but carries the same credential. Splitting at the first "?" is + // what keeps the path out of the query parser. + expect( + redactSpanUrlAttribute("url.full", `/api/oauth/callback?code=${CODE}&domain=example.test`), + ).toEqual({ + value: "/api/oauth/callback?domain=example.test", + stripped: ["code"], + }); + }); + + it("drops the trailing separator when a relative URL's only parameter goes", () => { + expect(redactSpanUrlAttribute("url.full", `/api/oauth/callback?code=${CODE}`)).toEqual({ + value: "/api/oauth/callback", + stripped: ["code"], + }); + }); + + it("leaves an unparseable value with no query string alone", () => { + expect(redactSpanUrlAttribute("url.full", "/api/integrations")).toBeNull(); + }); +}); + +describe("redactUrlQueryInText", () => { + it("strips a credential from a URL quoted inside a message", () => { + const message = `GET https://mcp.test/mcp?token=${TOKEN}&team=acme failed with 401`; + const scrubbed = redactUrlQueryInText(message); + + expect(scrubbed).not.toContain(TOKEN); + expect(scrubbed).toBe("GET https://mcp.test/mcp?team=acme failed with 401"); + }); + + it("strips a credential from a bare path with a query string", () => { + expect(redactUrlQueryInText(`redirected to /oauth/callback?code=${CODE} and stopped`)).toBe( + "redirected to /oauth/callback and stopped", + ); + }); + + it("leaves text with no URL untouched", () => { + expect(redactUrlQueryInText("the upstream rejected the request")).toBe( + "the upstream rejected the request", + ); + }); + + it("leaves a URL with no query string untouched", () => { + expect(redactUrlQueryInText("GET https://api.test/v1/things failed")).toBe( + "GET https://api.test/v1/things failed", + ); + }); + + it("scrubs every URL in a multi-line pretty cause", () => { + const pretty = [ + `RequestError: POST https://api.test/token?code=${CODE}`, + ` at fetch (https://app.test/assets/index.js?state=${STATE})`, + ].join("\n"); + + const scrubbed = redactUrlQueryInText(pretty); + + expect(scrubbed).not.toContain(CODE); + expect(scrubbed).not.toContain(STATE); + expect(scrubbed).toContain("https://api.test/token"); + }); +}); + +describe("redactSensitiveKeyValuesInText", () => { + it("replaces a credential-named value in serialized JSON", () => { + // The exact shape Effect's tracer logger produces for a logged cause: the + // tagged error is serialized INTO the event name, so no URL is involved. + const logged = `[\n "OAuth callback completion failed",\n {\n "state": "${STATE}",\n "_tag": "OAuthSessionNotFoundError"\n }\n]`; + const scrubbed = redactSensitiveKeyValuesInText(logged); + + expect(scrubbed).not.toContain(STATE); + expect(scrubbed).toContain('"state": "[redacted]"'); + // The part that makes the failure diagnosable survives. + expect(scrubbed).toContain('"_tag": "OAuthSessionNotFoundError"'); + expect(scrubbed).toContain("OAuth callback completion failed"); + }); + + it("replaces unquoted and single-quoted values", () => { + expect(redactSensitiveKeyValuesInText(`code=${CODE} status=400`)).toBe( + "code=[redacted] status=400", + ); + expect(redactSensitiveKeyValuesInText(`token: '${TOKEN}'`)).toBe("token: '[redacted]'"); + }); + + it("keeps a numeric value under a credential name", () => { + // `code: 404` is the readable part of a failure message, not a grant. + expect(redactSensitiveKeyValuesInText("failed with code: 404")).toBe("failed with code: 404"); + }); + + it("leaves non-credential keys alone", () => { + expect( + redactSensitiveKeyValuesInText('{"slug": "acme", "status": "failed", "encoding": "utf-8"}'), + ).toBe('{"slug": "acme", "status": "failed", "encoding": "utf-8"}'); + }); + + it("keeps an RFC 6749 error_description — it is the diagnostic, not a credential", () => { + // The AS's own explanation for rejecting the grant. `oauth-helpers.ts` + // deliberately preserves it in the token-endpoint summary; blanking it here + // would undo that a layer later and leave a bare `invalid_grant`. + const logged = `{"error": "invalid_grant", "error_description": "Refresh token is expired or revoked", "code": "${CODE}"}`; + const scrubbed = redactSensitiveKeyValuesInText(logged); + + expect(scrubbed).toContain('"error_description": "Refresh token is expired or revoked"'); + expect(scrubbed).toContain('"error": "invalid_grant"'); + expect(scrubbed).not.toContain(CODE); + }); + + it("still drops error_description from a URL, where it rode back on a redirect", () => { + // Same name, different surface: in the address bar it is attacker-influenced + // text on the callback, and nothing reads the diagnostic from there. + expect( + redactSpanUrlAttribute( + "url.full", + "https://app.test/api/oauth/callback?error=access_denied&error_description=user+declined", + ), + ).toEqual({ + value: "https://app.test/api/oauth/callback?error=access_denied", + stripped: ["error_description"], + }); + }); +}); + +describe("truncateSpanText", () => { + it("caps text past the limit and records how much went", () => { + const capped = truncateSpanText("x".repeat(MAX_SPAN_TEXT_CHARS + 100)); + expect(capped.length).toBeLessThan(MAX_SPAN_TEXT_CHARS + 100); + expect(capped).toContain("truncated 100 chars"); + }); + + it("leaves text within the limit exactly as-is", () => { + expect(truncateSpanText("short")).toBe("short"); + }); +}); + +describe("scrubSpanText", () => { + it("applies both policies: the credential goes and the tail is capped", () => { + const stack = `Error: POST https://api.test/token?code=${CODE}\n${"frame\n".repeat(3_000)}`; + const scrubbed = scrubSpanText(stack); + + expect(scrubbed).not.toContain(CODE); + expect(scrubbed).toContain("truncated"); + expect(scrubbed.length).toBeLessThan(stack.length); + }); +}); diff --git a/packages/core/sdk/src/span-redaction.ts b/packages/core/sdk/src/span-redaction.ts new file mode 100644 index 000000000..32a88a5cb --- /dev/null +++ b/packages/core/sdk/src/span-redaction.ts @@ -0,0 +1,248 @@ +// --------------------------------------------------------------------------- +// Span-value redaction — the pure core every export seam shares. +// +// Two classes of value reach a trace backend carrying credentials, and neither +// is fixable by wrapping the secret in `Redacted`: the framework stamps them +// itself, from values that are already plain strings by the time it sees them. +// +// 1. URL attributes. Effect's `HttpMiddleware.tracer` and `HttpClient` stamp +// `url.full` / `url.query` unconditionally (they redact URL userinfo and +// configured header names, and nothing else), so an OAuth callback's +// `?code=…&state=…` and a user-supplied `?token=…` endpoint ride out with +// the span. +// 2. Free text. Effect's tracer logger puts the already-interpolated message +// in the event name and the whole `effect.cause` in an event attribute; +// the OTel bridge's `recordException` does the same for `exception.message` +// and `exception.stacktrace`. Any URL those quote comes with its query +// string attached. +// +// This module owns the decisions — which parameters are sensitive, how deep to +// follow nesting, how much stack text is worth keeping — so the isolate-specific +// wrappers (an OTel `SpanProcessor` in the Workers isolates, an OTLP +// serialization wrapper in the browser) stay thin and cannot drift apart. +// --------------------------------------------------------------------------- + +/** Credentials, single-use grants, and CSRF secrets, in both places they are + * scrubbed. Matched case-insensitively against the key name. */ +const SENSITIVE_KEYS: readonly string[] = [ + "access_token", + "client_secret", + "code", + "code_verifier", + "id_token", + "refresh_token", + "session_state", + "state", + "token", +]; + +/** Query parameters dropped from a URL. Adds `error_description`: on a REDIRECT + * back from an authorization server it is attacker-influenced text that has + * landed in the address bar, and the URL attribute is not where it is read + * from anyway. */ +const SENSITIVE_QUERY_KEYS: ReadonlySet = new Set([...SENSITIVE_KEYS, "error_description"]); + +/** Key names replaced in free text. Deliberately WITHOUT `error_description`: + * in a failure message it is the RFC 6749 §5.2 explanation the authorization + * server gave for rejecting the grant ("Token is not valid", "redirect_uri + * mismatch"), which is the whole diagnostic and carries no credential — + * `oauth-helpers.ts` preserves it in the token-endpoint summary for the same + * reason, and blanking it here would undo that a layer later. */ +const SENSITIVE_TEXT_KEYS: ReadonlySet = new Set(SENSITIVE_KEYS); + +/** Span attributes whose value is a whole URL. */ +export const SPAN_URL_ATTRIBUTES = ["url.full", "http.url"] as const; +/** The span attribute holding a bare query string. */ +export const SPAN_QUERY_ATTRIBUTE = "url.query"; +/** Names of the parameters removed from this span's URL attributes. Non-secret + * by construction — it is the key list, never the values. */ +export const STRIPPED_QUERY_ATTRIBUTE = "url.query.stripped_keys"; + +const isSensitiveQueryKey = (key: string): boolean => SENSITIVE_QUERY_KEYS.has(key.toLowerCase()); + +const isSensitiveTextKey = (key: string): boolean => SENSITIVE_TEXT_KEYS.has(key.toLowerCase()); + +/** How deep to follow a query parameter whose value is itself a URL or path + * with a query string. Depth 2 covers the real nesting in this app — + * `/login?returnTo=%2Fapi%2Foauth%2Fcallback%3Fcode%3D…` — with headroom, and + * bounds the work per span. */ +const MAX_NESTED_DEPTH = 2; + +/** A scrubbed value plus the names of the parameters that were removed. */ +export interface SpanRedaction { + readonly value: string; + readonly stripped: readonly string[]; +} + +/** The query string with every sensitive parameter dropped, plus the names + * dropped. Parsed with `URLSearchParams` so encoding round-trips correctly. + * + * A parameter whose own value carries a nested query string is redacted + * recursively rather than dropped: the login redirect round-trips the whole + * OAuth callback path through `returnTo`, so the credential rides inside + * another parameter's value and a top-level key check alone would miss it. */ +export const redactQuery = (query: string, depth = 0): SpanRedaction => { + const params = new URLSearchParams(query); + const stripped = new Set(); + for (const key of Array.from(new Set(params.keys()))) { + if (isSensitiveQueryKey(key)) { + stripped.add(key); + params.delete(key); + continue; + } + if (depth >= MAX_NESTED_DEPTH) continue; + const values = params.getAll(key); + const rewritten = values.map((value) => { + const separator = value.indexOf("?"); + if (separator === -1) return value; + const nested = redactQuery(value.slice(separator + 1), depth + 1); + for (const name of nested.stripped) stripped.add(`${key}.${name}`); + return nested.stripped.length === 0 + ? value + : `${value.slice(0, separator)}${nested.value === "" ? "" : `?${nested.value}`}`; + }); + if (rewritten.every((value, index) => value === values[index])) continue; + params.delete(key); + for (const value of rewritten) params.append(key, value); + } + return stripped.size === 0 + ? { value: query, stripped: [] } + : { value: params.toString(), stripped: Array.from(stripped).sort() }; +}; + +/** The URL with every sensitive query parameter dropped. Path, host, and + * scheme are untouched. + * + * A value `URL` cannot parse is still scrubbed rather than passed through: a + * `url.full` stamped from a relative request (`/api/oauth/callback?code=…`) + * has no origin to parse but carries the same credential, so it is split at + * its first "?" and only the tail goes through the query scrub — feeding the + * whole string to `URLSearchParams` would swallow the path into a parameter + * name and rewrite the attribute into something unreadable. */ +export const redactUrl = (url: string): SpanRedaction => { + if (!URL.canParse(url)) { + const separator = url.indexOf("?"); + if (separator === -1) return { value: url, stripped: [] }; + const query = redactQuery(url.slice(separator + 1)); + if (query.stripped.length === 0) return { value: url, stripped: [] }; + const path = url.slice(0, separator); + return { + value: query.value === "" ? path : `${path}?${query.value}`, + stripped: query.stripped, + }; + } + const parsed = new URL(url); + if (parsed.search === "") return { value: url, stripped: [] }; + const query = redactQuery(parsed.search.slice(1)); + if (query.stripped.length === 0) return { value: url, stripped: [] }; + parsed.search = query.value; + return { value: parsed.toString(), stripped: query.stripped }; +}; + +/** Scrub one span attribute by name. Returns `null` when the attribute is not + * URL-bearing, is not a string, or carried nothing sensitive — so a non-null + * result always means "rewrite this value". */ +export const redactSpanUrlAttribute = (name: string, value: unknown): SpanRedaction | null => { + if (typeof value !== "string") return null; + const isUrl = (SPAN_URL_ATTRIBUTES as readonly string[]).includes(name); + if (!isUrl && name !== SPAN_QUERY_ATTRIBUTE) return null; + const result = isUrl ? redactUrl(value) : redactQuery(value); + return result.stripped.length === 0 ? null : result; +}; + +/** Rewrites the URL-bearing attributes of a span's attribute bag in place, + * dropping sensitive query parameters. Returns the parameter names removed. */ +export const redactSpanUrlAttributes = (attributes: Record): readonly string[] => { + const stripped = new Set(); + for (const name of [...SPAN_URL_ATTRIBUTES, SPAN_QUERY_ATTRIBUTE]) { + const result = redactSpanUrlAttribute(name, attributes[name]); + if (result === null) continue; + for (const key of result.stripped) stripped.add(key); + attributes[name] = result.value; + } + return Array.from(stripped).sort(); +}; + +// --------------------------------------------------------------------------- +// Free text — event names, exception messages, pretty-printed causes +// --------------------------------------------------------------------------- + +/** A URL-shaped substring that HAS a query string: an absolute URL or an + * absolute path, up to the first `?`, then the query up to the next + * whitespace or closing delimiter. Requiring the `?` is the point — a URL with + * no query string has nothing to strip, so it is left alone. */ +const URL_WITH_QUERY = /(?:[a-z][a-z0-9+.-]*:\/\/|\/)[^\s"'<>)\]}\\?]*\?[^\s"'<>)\]}\\]*/gi; + +/** Drop sensitive query parameters from every URL-shaped substring of free + * text (a log message, an exception message, a pretty-printed cause). */ +export const redactUrlQueryInText = (text: string): string => + text.replace(URL_WITH_QUERY, (match) => { + const separator = match.indexOf("?"); + const { value, stripped } = redactQuery(match.slice(separator + 1)); + if (stripped.length === 0) return match; + return value === "" ? match.slice(0, separator) : `${match.slice(0, separator)}?${value}`; + }); + +/** The marker left where a credential-named value was removed from free text. + * Deliberately not `Redacted`'s own "" rendering, so a scrubbed span + * value is never mistaken for a value serialized while still wrapped. */ +const TEXT_SCRUB_MARKER = "[redacted]"; + +/** A `` pair, where the separator is `=` or `:` with + * optional quoting and whitespace around either side. Covers the three shapes + * free text actually arrives in: the JSON a serialized error renders to + * (`"state": "…"`), form-encoded prose (`code=…`), and hand-written messages + * (`token: …`). The value runs to the closing quote, or to the next + * whitespace/delimiter when unquoted. */ +const KEY_VALUE_PAIR = + /(["']?)([a-z_][a-z0-9_]*)\1(\s*[=:]\s*)(?:"([^"]*)"|'([^']*)'|([^\s,;)\]}]+))/gi; + +/** A value that is purely numeric. Kept verbatim: `code: 404` and + * `status_code=200` are the readable part of a failure message, and a bare + * integer is not a credential. */ +const NUMERIC_VALUE = /^\d+$/; + +/** Replace the value of any credential-named key in free text. + * + * This is the second half of the free-text backstop, and it exists because the + * first half is not enough: Effect's tracer logger serializes a logged CAUSE + * into the event name, so a tagged error's `state` / `code` / `token` field + * reaches the exporter as JSON inside a string — no URL involved, nothing for + * `Redacted` to have wrapped, and no chance for the URL scrub to see it. + * + * Matching is by KEY NAME, so it cannot depend on recognizing a secret by + * shape. A non-credential value that happens to sit under one of those names is + * lost from the trace; that is the accepted cost of the backstop, bounded by + * keeping numeric values and by holding `error_description` out of the text set + * (see `SENSITIVE_TEXT_KEYS`). */ +export const redactSensitiveKeyValuesInText = (text: string): string => + text.replace(KEY_VALUE_PAIR, (match, quote, key, separator, doubleQuoted, singleQuoted, bare) => { + if (!isSensitiveTextKey(key)) return match; + const value = doubleQuoted ?? singleQuoted ?? bare ?? ""; + if (value === "" || NUMERIC_VALUE.test(value)) return match; + const rendered = + doubleQuoted !== undefined + ? `"${TEXT_SCRUB_MARKER}"` + : singleQuoted !== undefined + ? `'${TEXT_SCRUB_MARKER}'` + : TEXT_SCRUB_MARKER; + return `${quote}${key}${quote}${separator}${rendered}`; + }); + +/** The cap applied to free-text span values. Stack traces and pretty-printed + * causes are unbounded — a deep Effect cause runs to tens of kilobytes — and + * the tail is where framework frames, echoed request bodies, and quoted + * upstream responses live. Truncating is the policy rather than parsing: the + * head carries the failure, the tail carries the risk. */ +export const MAX_SPAN_TEXT_CHARS = 8_000; + +/** Cap free text, recording how much was dropped. */ +export const truncateSpanText = (text: string, maxChars = MAX_SPAN_TEXT_CHARS): string => + text.length <= maxChars + ? text + : `${text.slice(0, maxChars)}\n…[truncated ${text.length - maxChars} chars]`; + +/** The whole policy for a free-text value leaving the process on a span: URL + * query parameters stripped, credential-named values replaced, then capped. */ +export const scrubSpanText = (text: string, maxChars = MAX_SPAN_TEXT_CHARS): string => + truncateSpanText(redactSensitiveKeyValuesInText(redactUrlQueryInText(text)), maxChars); diff --git a/packages/core/sdk/src/telemetry-endpoint.test.ts b/packages/core/sdk/src/telemetry-endpoint.test.ts index 3d9a8ac62..497f29dc3 100644 --- a/packages/core/sdk/src/telemetry-endpoint.test.ts +++ b/packages/core/sdk/src/telemetry-endpoint.test.ts @@ -35,6 +35,12 @@ describe("endpointForTelemetry", () => { ); }); + it("does not URL-normalize a credential-free endpoint", () => { + // A bare origin round-tripped through URL gains a trailing slash; the + // stamped attribute must stay byte-identical to the configured endpoint. + expect(endpointForTelemetry("http://127.0.0.1:55003")).toBe("http://127.0.0.1:55003"); + }); + it("returns unparseable input as-is", () => { expect(endpointForTelemetry("not a url")).toBe("not a url"); }); diff --git a/packages/core/sdk/src/telemetry-endpoint.ts b/packages/core/sdk/src/telemetry-endpoint.ts index cba48a721..14e6590d9 100644 --- a/packages/core/sdk/src/telemetry-endpoint.ts +++ b/packages/core/sdk/src/telemetry-endpoint.ts @@ -23,6 +23,12 @@ export const endpointForTelemetry = (endpoint: string): string => { if (!URL.canParse(endpoint)) return endpoint; const url = new URL(endpoint); + // A clean endpoint is returned verbatim: round-tripping through URL + // normalizes (e.g. appends a trailing slash to an origin), which would make + // the stamped attribute diverge from the configured value. + if (url.search === "" && url.hash === "" && url.username === "" && url.password === "") { + return endpoint; + } url.search = ""; url.hash = ""; url.username = ""; diff --git a/packages/core/sdk/src/test-config.ts b/packages/core/sdk/src/test-config.ts index bce9392b4..7373eac2b 100644 --- a/packages/core/sdk/src/test-config.ts +++ b/packages/core/sdk/src/test-config.ts @@ -1,11 +1,11 @@ -import { Context, Effect, Layer } from "effect"; +import { Context, Effect, Layer, Redacted } from "effect"; import { withQueryContext } from "@executor-js/fumadb/query"; import { collectTables, createExecutor, type Executor, type ExecutorConfig } from "./executor"; import type { FumaDb } from "./fuma-runtime"; import { ProviderItemId, ProviderKey, Subject, Tenant } from "./ids"; import { definePlugin, type AnyPlugin } from "./plugin"; import type { ExecutorOwnerPolicyContext } from "./owner-policy"; -import type { CredentialProvider } from "./provider"; +import { credentialValueToWrite, type CredentialProvider } from "./provider"; import type { SqliteTestFumaDb } from "./sqlite-test-db"; // --------------------------------------------------------------------------- @@ -231,10 +231,14 @@ export const memoryCredentialsPlugin = definePlugin(() => { const provider: CredentialProvider = { key: ProviderKey.make("memory"), writable: true, - get: (id) => Effect.sync(() => store.get(String(id)) ?? null), + get: (id) => + Effect.sync(() => { + const value = store.get(String(id)); + return value === undefined ? null : Redacted.make(value); + }), set: (id, value) => Effect.sync(() => { - store.set(String(id), value); + store.set(String(id), credentialValueToWrite(value)); }), delete: (id) => Effect.sync(() => { diff --git a/packages/core/sdk/src/tools-list-merge.test.ts b/packages/core/sdk/src/tools-list-merge.test.ts index c15a3518c..c4307785f 100644 --- a/packages/core/sdk/src/tools-list-merge.test.ts +++ b/packages/core/sdk/src/tools-list-merge.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolName } from "./ids"; import { definePlugin } from "./plugin"; @@ -41,7 +41,10 @@ const demoPlugin = definePlugin(() => ({ // generics; annotating them over-constrains the inferred shape (the canonical // `connections.test.ts` relies on the same inference). invokeTool: ({ toolRow, credential }) => - Effect.succeed({ ran: toolRow.name, value: credential.value }), + Effect.succeed({ + ran: toolRow.name, + value: credential.value === null ? null : Redacted.value(credential.value), + }), extension: (ctx) => ({ seed: () => ctx.core.integrations.register({ diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-cause-span.test.ts b/packages/hosts/cloudflare/src/mcp/agent-session-cause-span.test.ts new file mode 100644 index 000000000..667344eca --- /dev/null +++ b/packages/hosts/cloudflare/src/mcp/agent-session-cause-span.test.ts @@ -0,0 +1,59 @@ +// --------------------------------------------------------------------------- +// `recordCauseOnSpan` stamps the failing cause as plain span ATTRIBUTES, not as +// an exception event, so neither the export seam's URL scrubber (which only +// looks at URL attributes) nor its event scrub reaches them. It runs the text +// through the shared scrub itself. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; +import { Cause } from "effect"; + +import { causeSpanAttributes } from "./agent-session-durable-object"; + +// Synthetic placeholders only. +const QUERY_TOKEN = "synthetic-endpoint-token"; +const CODE = "synthetic-authorization-code"; + +describe("causeSpanAttributes", () => { + it("scrubs a credential out of the message and the pretty cause", () => { + const attributes = causeSpanAttributes( + Cause.fail( + // oxlint-disable-next-line executor/no-error-constructor -- the failure text is what is under test; a tagged error would hide it + new Error(`POST https://mcp.test/mcp?token=${QUERY_TOKEN} failed with 401`), + ), + ); + + expect(attributes).not.toBeNull(); + expect(attributes?.["exception.message"]).not.toContain(QUERY_TOKEN); + expect(attributes?.["exception.stacktrace"]).not.toContain(QUERY_TOKEN); + // The diagnosable part survives. + expect(attributes?.["exception.message"]).toContain("https://mcp.test/mcp"); + expect(attributes?.["exception.message"]).toContain("failed with 401"); + expect(attributes?.["exception.type"]).toBe("Error"); + }); + + it("scrubs a credential-named field of a serialized failure", () => { + const attributes = causeSpanAttributes( + // oxlint-disable-next-line executor/no-error-constructor -- the failure text is what is under test + Cause.fail(new Error(`callback rejected {"code": "${CODE}", "_tag": "OAuthError"}`)), + ); + + expect(attributes?.["exception.message"]).not.toContain(CODE); + expect(attributes?.["exception.message"]).toContain('"_tag": "OAuthError"'); + }); + + it("caps an unbounded pretty cause", () => { + // oxlint-disable-next-line executor/no-error-constructor -- a deep synthetic stack is the point + const deep = new Error("deep failure"); + deep.stack = `Error: deep failure\n${" at frame (do.js:1:1)\n".repeat(2_000)}`; + + const stacktrace = causeSpanAttributes(Cause.fail(deep))?.["exception.stacktrace"] ?? ""; + + expect(stacktrace).toContain("truncated"); + expect(stacktrace.length).toBeLessThan(10_000); + }); + + it("returns null for a cause with no errors", () => { + expect(causeSpanAttributes(Cause.empty)).toBeNull(); + }); +}); diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts index aa069be60..5ca94fc2c 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts @@ -19,6 +19,7 @@ import { type ResumeFallbackOutcome, } from "@executor-js/host-mcp/tool-server"; import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp"; +import { scrubSpanText } from "@executor-js/sdk/shared"; import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers"; import type { @@ -36,6 +37,26 @@ import { export type IncomingTraceHeaders = IncomingPropagationHeaders; +/** The `exception.*` attributes for a failing cause, or `null` when the cause + * carries no error. + * + * These land as plain span ATTRIBUTES, not as exception events and not as URL + * attributes, so neither the URL scrubber nor the event scrub at the export + * seam reaches them: the message and the pretty cause are free text that may + * quote an upstream request URL with its query string, and the pretty cause is + * unbounded. `scrubSpanText` is the same policy the export seam applies to + * event text. */ +export const causeSpanAttributes = (cause: Cause.Cause): Record | null => { + const errors = Cause.prettyErrors(cause); + if (errors.length === 0) return null; + const first = errors[0]; + return { + "exception.type": first?.name ?? "Error", + "exception.message": scrubSpanText(first?.message ?? "unknown"), + "exception.stacktrace": scrubSpanText(Cause.pretty(cause)), + }; +}; + export interface McpSessionInit { readonly organizationId: string; readonly userId: string; @@ -471,14 +492,8 @@ export abstract class McpAgentSessionDOBase< } private recordCauseOnSpan(cause: Cause.Cause): Effect.Effect { - const errors = Cause.prettyErrors(cause); - if (errors.length === 0) return Effect.void; - const first = errors[0]; - return Effect.annotateCurrentSpan({ - "exception.type": first?.name ?? "Error", - "exception.message": first?.message ?? "unknown", - "exception.stacktrace": Cause.pretty(cause), - }); + const attributes = causeSpanAttributes(cause); + return attributes === null ? Effect.void : Effect.annotateCurrentSpan(attributes); } private logExecutionOwnerDirectoryFailure(input: { diff --git a/packages/kernel/runtime-dynamic-worker/src/integration.test.ts b/packages/kernel/runtime-dynamic-worker/src/integration.test.ts index a08f94b51..f6e244d40 100644 --- a/packages/kernel/runtime-dynamic-worker/src/integration.test.ts +++ b/packages/kernel/runtime-dynamic-worker/src/integration.test.ts @@ -20,6 +20,7 @@ import { drizzle } from "drizzle-orm/postgres-js"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Predicate from "effect/Predicate"; +import * as Redacted from "effect/Redacted"; import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/unstable/http"; import { fumadb } from "@executor-js/fumadb"; import { @@ -32,6 +33,7 @@ import postgres from "postgres"; import { collectTables, createExecutor, + credentialValueToWrite, AuthTemplateSlug, ConnectionName, IntegrationSlug, @@ -71,9 +73,13 @@ const memoryProvider = (): CredentialProvider => { return { key: ProviderKey.make("memory"), writable: true, - get: (id: ProviderItemId) => Effect.sync(() => store.get(String(id)) ?? null), - set: (id: ProviderItemId, value: string) => - Effect.sync(() => void store.set(String(id), value)), + get: (id: ProviderItemId) => + Effect.sync(() => { + const value = store.get(String(id)); + return value === undefined ? null : Redacted.make(value); + }), + set: (id: ProviderItemId, value: string | Redacted.Redacted) => + Effect.sync(() => void store.set(String(id), credentialValueToWrite(value))), has: (id: ProviderItemId) => Effect.sync(() => store.has(String(id))), list: () => Effect.sync((): readonly ProviderEntry[] => diff --git a/packages/plugins/apps/src/plugin/apps-plugin.ts b/packages/plugins/apps/src/plugin/apps-plugin.ts index 5b83abdd1..267552209 100644 --- a/packages/plugins/apps/src/plugin/apps-plugin.ts +++ b/packages/plugins/apps/src/plugin/apps-plugin.ts @@ -1,5 +1,5 @@ /* oxlint-disable executor/no-try-catch-or-throw -- boundary: plugin source config validation is converted into the extension Effect failure channel */ -import { Data, Effect, Predicate, Result } from "effect"; +import { Data, Effect, Predicate, Redacted, Result } from "effect"; import { AuthTemplateSlug, ConnectionName, @@ -249,16 +249,21 @@ const tokenItemId = (slug: string): ProviderItemId => // A source authenticates only with its own explicitly provided token. No // implicit credential sharing: a stored GitHub connection must never leak // into git fetches the user did not tie to it. +// Unwrapped here rather than at the fetch sites because the git source API +// takes a bare bearer token; keeping it wrapped any longer would only move the +// unwrap, not remove it. const gitTokenFor = ( ctx: PluginCtx, config: Extract, ): Effect.Effect => Effect.gen(function* () { if (config.tokenProvider && config.tokenItemId) { - return yield* ctx.providers.get( + const token = yield* ctx.providers.get( ProviderKey.make(config.tokenProvider), ProviderItemId.make(config.tokenItemId), ); + // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the git source API takes a bare bearer token + return token === null ? null : Redacted.value(token); } return null; }); diff --git a/packages/plugins/encrypted-secrets/src/index.test.ts b/packages/plugins/encrypted-secrets/src/index.test.ts index 42ef88139..8636dea5d 100644 --- a/packages/plugins/encrypted-secrets/src/index.test.ts +++ b/packages/plugins/encrypted-secrets/src/index.test.ts @@ -1,4 +1,4 @@ -import { Effect, Exit } from "effect"; +import { Effect, Exit, Redacted } from "effect"; import { describe, expect, test } from "@effect/vitest"; import { Owner, ProviderItemId, type CredentialProvider, type PluginCtx } from "@executor-js/sdk"; @@ -82,6 +82,13 @@ const makeProvider = (key: string, owner: Owner = Owner.make("org")) => { const id = (value: string) => ProviderItemId.make(value); +/** Unwrap a `get` for comparison against the expected plaintext. Absence stays + * `null`, tested explicitly. */ +const resolved = async (effect: Effect.Effect | null, E>) => { + const value = await Effect.runPromise(effect); + return value === null ? null : Redacted.value(value); +}; + const expectFailure = async (effect: Effect.Effect) => { const exit = await Effect.runPromiseExit(effect); expect(Exit.isFailure(exit)).toBe(true); @@ -119,8 +126,7 @@ describe("provider", () => { test("set then get returns the plaintext", async () => { const { provider } = makeProvider("master"); await Effect.runPromise(provider.set!(id("github"), "ghp_xyz")); - const got = await Effect.runPromise(provider.get(id("github"))); - expect(got).toBe("ghp_xyz"); + expect(await resolved(provider.get(id("github")))).toBe("ghp_xyz"); }); test("stores ciphertext at rest, not plaintext", async () => { @@ -131,6 +137,23 @@ describe("provider", () => { expect(stored.startsWith("v1.")).toBe(true); }); + // A `set` that forgot to unwrap would encrypt the literal "", and + // the ciphertext-at-rest test above would still pass. Decrypting what was + // actually stored is the only check that catches it. + test("encrypts the real secret for both string and Redacted input", async () => { + const key = deriveKey("master"); + + for (const [itemId, input, expected] of [ + ["plain", "sk_plain_written", "sk_plain_written"], + ["wrapped", Redacted.make("sk_wrapped_written"), "sk_wrapped_written"], + ] as const) { + const { provider, rows } = makeProvider("master"); + await Effect.runPromise(provider.set!(id(itemId), input)); + const persisted = String([...rows.values()][0]!.data); + expect(await Effect.runPromise(decryptSecret(key, persisted))).toBe(expected); + } + }); + // removed: "a secret in one scope is invisible to another scope" — v2 drops // the scope arg entirely. The provider keys solely by the opaque // `ProviderItemId`; the referencing connection row owns the (tenant, owner, @@ -139,7 +162,7 @@ describe("provider", () => { test("get returns null for a missing id", async () => { const { provider } = makeProvider("master"); - expect(await Effect.runPromise(provider.get(id("absent")))).toBeNull(); + expect(await resolved(provider.get(id("absent")))).toBeNull(); }); test("has and delete reflect presence", async () => { diff --git a/packages/plugins/encrypted-secrets/src/index.ts b/packages/plugins/encrypted-secrets/src/index.ts index 8447fb7f0..b1fbeed30 100644 --- a/packages/plugins/encrypted-secrets/src/index.ts +++ b/packages/plugins/encrypted-secrets/src/index.ts @@ -1,8 +1,9 @@ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import { + credentialValueToWrite, definePlugin, Owner, ProviderItemId, @@ -96,14 +97,18 @@ const makeEncryptedProvider = ( storage .get({ collection: COLLECTION, key: id }) .pipe( - Effect.flatMap((entry) => (entry ? decryptSecret(key, entry.data) : Effect.succeed(null))), + Effect.flatMap((entry) => + entry + ? decryptSecret(key, entry.data).pipe(Effect.map(Redacted.make)) + : Effect.succeed(null), + ), ), has: (id: ProviderItemId) => storage.get({ collection: COLLECTION, key: id }).pipe(Effect.map((entry) => entry !== null)), - set: (id: ProviderItemId, value: string) => - encryptSecret(key, value).pipe( + set: (id: ProviderItemId, value: string | Redacted.Redacted) => + encryptSecret(key, credentialValueToWrite(value)).pipe( Effect.flatMap((payload) => storage.put({ collection: COLLECTION, key: id, owner, data: payload }), ), diff --git a/packages/plugins/file-secrets/src/data-dir.test.ts b/packages/plugins/file-secrets/src/data-dir.test.ts index 27e501393..7cd9f4eb9 100644 --- a/packages/plugins/file-secrets/src/data-dir.test.ts +++ b/packages/plugins/file-secrets/src/data-dir.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest" import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import { AuthTemplateSlug, @@ -105,7 +105,7 @@ describe("file secrets data directory", () => { // Regression: auth.json follows XDG_DATA_HOME instead of EXECUTOR_DATA_DIR. // After the fix it must live with test.db under dataDir and survive this home swap. expect( - resolved, + resolved === null ? null : Redacted.value(resolved), `credential persisted in ${firstAuthPath} was not available after recreating the sandbox with ${dataDir}`, ).toBe(CREDENTIAL); }), diff --git a/packages/plugins/file-secrets/src/index.test.ts b/packages/plugins/file-secrets/src/index.test.ts index d33f78431..6162eaf91 100644 --- a/packages/plugins/file-secrets/src/index.test.ts +++ b/packages/plugins/file-secrets/src/index.test.ts @@ -11,9 +11,9 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { Effect, Predicate, Result } from "effect"; +import { Effect, Predicate, Redacted, Result } from "effect"; -import { ProviderKey } from "@executor-js/sdk"; +import { ProviderItemId, ProviderKey, type CredentialProvider } from "@executor-js/sdk"; import { makeTestWorkspaceHarness } from "@executor-js/sdk/testing"; import { fileSecretsPlugin } from "./index"; @@ -37,6 +37,11 @@ const writeAuthFile = (filePath: string, contents: string, mode = 0o600): void = writeFileSync(filePath, contents, { mode }); }; +const providerOf = (plugin: ReturnType): CredentialProvider => { + const credentialProviders = plugin.credentialProviders as () => readonly CredentialProvider[]; + return credentialProviders()[0]!; +}; + describe("file secrets auth location", () => { let workDir: string; let dataDir: string; @@ -178,6 +183,32 @@ describe("file secrets auth location", () => { ), ); + // auth.json is written with JSON.stringify, and a `Redacted` serializes to + // the string "" instead of throwing. A `set` that forgot to unwrap + // would therefore write a well-formed file full of useless placeholders, and + // a `get`-based round-trip would report it as stored correctly. Reading the + // file's raw bytes is what makes that failure loud. + it.effect("writes the real secret to auth.json for both string and Redacted input", () => + Effect.gen(function* () { + vi.stubEnv("EXECUTOR_DATA_DIR", dataDir); + const provider = providerOf(fileSecretsPlugin()); + + yield* provider.set!(ProviderItemId.make("plain"), "sk_plain_written"); + yield* provider.set!(ProviderItemId.make("wrapped"), Redacted.make("sk_wrapped_written")); + + // Asserted as raw text, not parsed: the bytes on disk are the thing under + // test, and the sibling migration cases assert the same way. + const onDisk = readFileSync(join(dataDir, "auth.json"), "utf8"); + expect(onDisk).toContain('"plain": "sk_plain_written"'); + expect(onDisk).toContain('"wrapped": "sk_wrapped_written"'); + expect(onDisk).not.toContain(""); + + const read = yield* provider.get(ProviderItemId.make("wrapped")); + expect(read === null ? null : Redacted.value(read)).toBe("sk_wrapped_written"); + expect(yield* provider.get(ProviderItemId.make("absent"))).toBeNull(); + }), + ); + it.effect("shares one migration across concurrent first provider operations", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/plugins/file-secrets/src/index.ts b/packages/plugins/file-secrets/src/index.ts index 7b3015286..6844f8717 100644 --- a/packages/plugins/file-secrets/src/index.ts +++ b/packages/plugins/file-secrets/src/index.ts @@ -1,9 +1,10 @@ import { randomUUID } from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; -import { Deferred, Effect, Exit, Schema } from "effect"; +import { Deferred, Effect, Exit, Redacted, Schema } from "effect"; import { + credentialValueToWrite, definePlugin, ProviderItemId, ProviderKey, @@ -237,7 +238,10 @@ const makeFileProvider = (location: AuthLocation): CredentialProvider => { get: (id: ProviderItemId) => ensureMigration.pipe( Effect.andThen(Effect.suspend(() => readAll(location.filePath))), - Effect.map((data) => data[id] ?? null), + Effect.map((data) => { + const value = data[id]; + return value === undefined ? null : Redacted.make(value); + }), ), has: (id: ProviderItemId) => @@ -246,12 +250,12 @@ const makeFileProvider = (location: AuthLocation): CredentialProvider => { Effect.map((data) => id in data), ), - set: (id: ProviderItemId, value: string) => + set: (id: ProviderItemId, value: string | Redacted.Redacted) => ensureMigration.pipe( Effect.andThen( Effect.gen(function* () { const data = yield* readAll(location.filePath); - data[id] = value; + data[id] = credentialValueToWrite(value); yield* writeAll(location.filePath, data); }), ), diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 1363b3a07..d8aba6829 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -1,5 +1,5 @@ import { Effect, Match, Option, Schema } from "effect"; -import type { Layer } from "effect"; +import type { Layer, Redacted } from "effect"; import { HttpClient } from "effect/unstable/http"; import { @@ -433,7 +433,7 @@ const annotationsFor = (binding: OperationBinding): ToolAnnotations => { const renderGraphqlAuthMethod = ( method: GraphqlAuthMethod, - values: Record, + values: Record | null>, ): RenderedAuthPlacements => { if (method.kind === "apikey") return renderAuthPlacements(method.placements, values); if (method.kind === "oauth2") { @@ -470,7 +470,7 @@ const toStoredOperations = ( * auth-required endpoint introspects successfully here rather than at add-time. */ const introspectHeadersForConnection = ( config: GraphqlIntegrationConfig, - values: Record, + values: Record | null>, templateSlug: AuthTemplateSlug | null, ): RenderedAuthPlacements => { const headers: Record = { ...(config.headers ?? {}) }; @@ -511,7 +511,7 @@ const loadIntrospectionJson = ( const introspectForConnection = ( config: GraphqlIntegrationConfig, introspectionJson: string | null, - values: Record, + values: Record | null>, templateSlug: AuthTemplateSlug | null, httpClientLayer: Layer.Layer, ): Effect.Effect => { @@ -536,7 +536,7 @@ const materializeOperations = ( config: GraphqlIntegrationConfig, credential: { readonly template: AuthTemplateSlug; - readonly values: Record; + readonly values: Record | null>; }, httpClientLayer: Layer.Layer, ): Effect.Effect => @@ -972,7 +972,10 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { readonly config: IntegrationConfig; readonly template: AuthTemplateSlug | null; readonly storage: GraphqlStore; - readonly getValues: () => Effect.Effect, unknown>; + readonly getValues: () => Effect.Effect< + Record | null>, + unknown + >; readonly httpClientLayer: Layer.Layer; }) => Effect.gen(function* () { @@ -992,9 +995,11 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { const values = introspectionJson == null ? yield* getValues().pipe( - Effect.catch(() => Effect.succeed({} as Record)), + Effect.catch(() => + Effect.succeed({} as Record | null>), + ), ) - : ({} as Record); + : ({} as Record | null>); const introspection = yield* introspectForConnection( graphqlConfig, introspectionJson, diff --git a/packages/plugins/keychain/src/index.test.ts b/packages/plugins/keychain/src/index.test.ts index 07a24c5d2..5dbda4720 100644 --- a/packages/plugins/keychain/src/index.test.ts +++ b/packages/plugins/keychain/src/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import { ProviderItemId, ProviderKey, createExecutor } from "@executor-js/sdk"; import { makeTestConfig } from "@executor-js/sdk/testing"; import { keychainPlugin } from "./index"; @@ -77,7 +77,7 @@ describe("keychain plugin", () => { // Provider resolves the value back. const resolved = yield* provider.get(id); - expect(resolved).toBe("keychain-test-value"); + expect(resolved === null ? null : Redacted.value(resolved)).toBe("keychain-test-value"); }).pipe(Effect.ensuring(provider.delete!(id).pipe(Effect.orElseSucceed(() => undefined)))); }), ); diff --git a/packages/plugins/keychain/src/provider.test.ts b/packages/plugins/keychain/src/provider.test.ts new file mode 100644 index 000000000..36f0160ab --- /dev/null +++ b/packages/plugins/keychain/src/provider.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Redacted } from "effect"; +// oxlint-disable-next-line executor/no-vitest-import -- boundary: vi.mock/vi.hoisted must come from vitest itself for mock hoisting to resolve +import { vi } from "vitest"; + +import { ProviderItemId } from "@executor-js/sdk"; + +const keyring = vi.hoisted(() => { + // The bytes the OS keychain would have received. Asserting on these instead + // of on a `get` round-trip is what catches a `set` that stored the wrong + // thing but reads back consistently. + const stored = new Map(); + const key = (serviceName: string, account: string) => `${serviceName} ${account}`; + return { + stored, + key, + getPassword: vi.fn((serviceName: string, account: string) => + Effect.succeed(stored.get(key(serviceName, account)) ?? null), + ), + setPassword: vi.fn((serviceName: string, account: string, value: string) => + Effect.sync(() => { + stored.set(key(serviceName, account), value); + }), + ), + deletePassword: vi.fn((serviceName: string, account: string) => + Effect.sync(() => stored.delete(key(serviceName, account))), + ), + }; +}); + +vi.mock("./keyring", () => ({ + getPassword: keyring.getPassword, + setPassword: keyring.setPassword, + deletePassword: keyring.deletePassword, +})); + +const { makeKeychainProvider } = await import("./provider"); + +const SERVICE = "executor-test-provider"; + +describe("keychain credential provider", () => { + // A `set` that forgot to unwrap would hand the keychain the literal + // "" — `Redacted`'s toString renders that rather than throwing — and + // a `get` round-trip would still look self-consistent. + it.effect("writes the real secret to the keychain for both string and Redacted input", () => + Effect.gen(function* () { + keyring.stored.clear(); + const provider = makeKeychainProvider(SERVICE); + + yield* provider.set!(ProviderItemId.make("plain"), "sk_plain_written"); + yield* provider.set!(ProviderItemId.make("wrapped"), Redacted.make("sk_wrapped_written")); + + expect(keyring.stored.get(keyring.key(SERVICE, "plain"))).toBe("sk_plain_written"); + expect(keyring.stored.get(keyring.key(SERVICE, "wrapped"))).toBe("sk_wrapped_written"); + expect([...keyring.stored.values()]).not.toContain(""); + }), + ); + + it.effect("returns stored values as Redacted and absence as null", () => + Effect.gen(function* () { + keyring.stored.clear(); + const provider = makeKeychainProvider(SERVICE); + + yield* provider.set!(ProviderItemId.make("token"), "sk_round_trip"); + + const found = yield* provider.get(ProviderItemId.make("token")); + expect(found).not.toBeNull(); + expect(Redacted.isRedacted(found)).toBe(true); + expect(found === null ? null : Redacted.value(found)).toBe("sk_round_trip"); + + expect(yield* provider.get(ProviderItemId.make("absent"))).toBeNull(); + expect(yield* provider.has!(ProviderItemId.make("token"))).toBe(true); + expect(yield* provider.has!(ProviderItemId.make("absent"))).toBe(false); + }), + ); + + // An empty secret is a value, not an absence. + it.effect("round-trips an empty value as present", () => + Effect.gen(function* () { + keyring.stored.clear(); + const provider = makeKeychainProvider(SERVICE); + + yield* provider.set!(ProviderItemId.make("empty"), Redacted.make("")); + + expect(keyring.stored.get(keyring.key(SERVICE, "empty"))).toBe(""); + const found = yield* provider.get(ProviderItemId.make("empty")); + expect(found).not.toBeNull(); + expect(found === null ? null : Redacted.value(found)).toBe(""); + }), + ); +}); diff --git a/packages/plugins/keychain/src/provider.ts b/packages/plugins/keychain/src/provider.ts index e72de55e3..636d3cb29 100644 --- a/packages/plugins/keychain/src/provider.ts +++ b/packages/plugins/keychain/src/provider.ts @@ -1,6 +1,7 @@ -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import { + credentialValueToWrite, StorageError, ProviderKey, type CredentialProvider, @@ -40,14 +41,20 @@ const KEYCHAIN_PROVIDER_KEY = ProviderKey.make("keychain"); export const makeKeychainProvider = (serviceName: string): CredentialProvider => ({ key: KEYCHAIN_PROVIDER_KEY, writable: true, - get: (id: ProviderItemId) => getPassword(serviceName, id).pipe(Effect.mapError(toStorageError)), + get: (id: ProviderItemId) => + getPassword(serviceName, id).pipe( + Effect.map((value: string | null) => (value === null ? null : Redacted.make(value))), + Effect.mapError(toStorageError), + ), has: (id: ProviderItemId) => getPassword(serviceName, id).pipe( Effect.map((value: string | null) => value !== null), Effect.mapError(toStorageError), ), - set: (id: ProviderItemId, value: string) => - setPassword(serviceName, id, value).pipe(Effect.mapError(toStorageError)), + set: (id: ProviderItemId, value: string | Redacted.Redacted) => + setPassword(serviceName, id, credentialValueToWrite(value)).pipe( + Effect.mapError(toStorageError), + ), delete: (id: ProviderItemId) => deletePassword(serviceName, id).pipe(Effect.asVoid, Effect.mapError(toStorageError)), // Keychain doesn't support enumerating — you need to know the account name. diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index 15363b233..0ab983f69 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -434,7 +434,7 @@ describe("mcpPlugin", () => { tokenUrl: "https://mcp.axiom.co/token", grant: "authorization_code", clientId: "stale-dcr-client", - clientSecret: "", + clientSecret: null, resource: "https://mcp.axiom.co/mcp", }); yield* executor.oauth.createClient({ @@ -444,7 +444,7 @@ describe("mcpPlugin", () => { tokenUrl: "https://mcp.axiom.co/token", grant: "authorization_code", clientId: "manual-client", - clientSecret: "", + clientSecret: null, resource: "https://mcp.axiom.co/mcp", }); diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 5e1b01087..88cdac113 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, Option, Result, Schema } from "effect"; +import { Effect, Layer, Option, Redacted, Result, Schema } from "effect"; import type { HttpClient } from "effect/unstable/http"; import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; @@ -13,6 +13,7 @@ import { endpointTelemetryAttributes, IntegrationAlreadyExistsError, IntegrationSlug, + makeCredentialScrubber, mergeAuthTemplates, OAuthClientSlug, tool, @@ -497,7 +498,7 @@ export const userFacingProbeMessage = ( // a new flow and fails loudly if the SDK tries to. // --------------------------------------------------------------------------- -const makeOAuthProvider = (accessToken: string): OAuthClientProvider => ({ +const makeOAuthProvider = (accessToken: Redacted.Redacted): OAuthClientProvider => ({ get redirectUrl() { return "http://localhost/oauth/callback"; }, @@ -512,7 +513,12 @@ const makeOAuthProvider = (accessToken: string): OAuthClientProvider => ({ }, clientInformation: () => undefined, saveClientInformation: () => undefined, - tokens: () => ({ access_token: accessToken, token_type: "Bearer" }), + // Boundary: the MCP SDK's OAuthClientProvider hands `access_token` straight + // to the transport's Authorization header and its type is a plain string, so + // the token must be unwrapped to cross into third-party code. Kept inside the + // accessor so the unwrapped value exists only for the duration of a dial. + // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the MCP SDK's OAuthClientProvider hands `access_token` straight to the transport's Authorization header + tokens: () => ({ access_token: Redacted.value(accessToken), token_type: "Bearer" }), saveTokens: () => undefined, redirectToAuthorization: async () => { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: MCP SDK OAuthClientProvider callback can only signal reauthorization by throwing @@ -554,7 +560,7 @@ const selectAuthMethod = ( const buildConnectorInput = ( config: McpIntegrationConfigType, - values: Record, + values: Record | null>, templateSlug: string | null, allowStdio: boolean, httpClientLayer?: Layer.Layer, @@ -578,7 +584,10 @@ const buildConnectorInput = ( if (method?.kind === "stdio_env") { for (const variable of method.vars) { const value = values[variable]; - if (value != null) env[variable] = value; + // Boundary: the child process's environment block is a wire format — + // it takes strings. This is the stdio twin of `renderPlacementValue`. + // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the child process's environment block is a wire format + if (value != null) env[variable] = Redacted.value(value); } } return Effect.succeed({ @@ -629,10 +638,33 @@ const sortedRecord = ( .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), ); +// Boundary: the key is an in-process Map key that decides whether two +// invocations may SHARE an open session, so it must distinguish two accounts on +// the same endpoint. `Redacted` renders "" under JSON.stringify, which +// would make every credential on an endpoint look identical — and for an oauth2 +// method the token appears nowhere else in the key (it is carried by the +// authProvider, not the rendered headers), so distinct users would be served +// each other's pooled connection. Unwrapped deliberately: the key never leaves +// this process and is never logged or spanned, and the pooled connection it +// indexes already holds the same credential in its live transport, so the key +// opens no exposure window the pool did not already have. +const credentialIdentity = ( + values: Record | null>, +): Record => + sortedRecord( + Object.fromEntries( + Object.entries(values).map(([variable, value]) => [ + variable, + // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the pool key must differ when the credential differs; it never leaves this process + value === null ? null : Redacted.value(value), + ]), + ), + ); + const connectionPoolKey = ( input: Extract, template: string, - values: Record, + values: Record | null>, ): string => JSON.stringify({ endpoint: input.endpoint, @@ -641,7 +673,7 @@ const connectionPoolKey = ( headers: sortedRecord(input.headers), queryParams: sortedRecord(input.queryParams), template, - values: sortedRecord(values), + values: credentialIdentity(values), }); // --------------------------------------------------------------------------- @@ -1226,7 +1258,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { // Discovery tolerates unresolved credentials (an open server lists // tools unauthenticated; a bad value just yields zero tools). const values = yield* getValues().pipe( - Effect.orElseSucceed(() => ({}) as Record), + Effect.orElseSucceed(() => ({}) as Record | null>), ); const built = yield* buildConnectorInput( @@ -1527,6 +1559,11 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { // light up. checkHealth: ({ ctx, credential }) => Effect.gen(function* () { + // A dial failure's message can echo the request back (the endpoint with + // its query token, a rejected header), and the message lands verbatim in + // the persisted `detail`. Scrub it with the same helper the OpenAPI + // probe uses. + const scrub = makeCredentialScrubber(credential.values); const parsed = parseMcpIntegrationConfig(credential.config); if (!parsed) { return { status: "unknown" as const, checkedAt: Date.now() } satisfies HealthCheckResult; @@ -1549,12 +1586,14 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { status: mcpLivenessFailureStatus(error), checkedAt: Date.now(), ...(error.httpStatus !== undefined ? { httpStatus: error.httpStatus } : {}), - detail: error.message, + detail: scrub.text(error.message), } satisfies HealthCheckResult), ), ); }).pipe( // buildConnectorInput rejects (e.g. stdio disabled / missing config). + // Reached before the scrubber above exists, and these messages are + // plugin-authored configuration errors carrying no resolved value. Effect.catchTag("McpConnectionError", (error) => Effect.succeed({ status: mcpLivenessFailureStatus(error), diff --git a/packages/plugins/onepassword/src/sdk/plugin.ts b/packages/plugins/onepassword/src/sdk/plugin.ts index acc997965..af1c01d11 100644 --- a/packages/plugins/onepassword/src/sdk/plugin.ts +++ b/packages/plugins/onepassword/src/sdk/plugin.ts @@ -1,4 +1,4 @@ -import { Effect, Schema } from "effect"; +import { Effect, Redacted, Schema } from "effect"; import { definePlugin, @@ -203,21 +203,21 @@ const makeProvider = ( key: PROVIDER_KEY, writable: false, - get: (id: ProviderItemId): Effect.Effect => + get: (id: ProviderItemId): Effect.Effect | null, StorageFailure> => ctx.storage.getConfig().pipe( Effect.flatMap((config) => { - if (!config) return Effect.succeed(null as string | null); + if (!config) return Effect.succeed | null>(null); const uri = configuredVaultUri(config, id); - if (uri === null) return Effect.succeed(null as string | null); + if (uri === null) return Effect.succeed | null>(null); return getServiceFromConfig(config, timeoutMs, preferSdk).pipe( Effect.flatMap((svc) => svc.resolveSecret(uri)), - Effect.map((v): string | null => v), + Effect.map((v): Redacted.Redacted | null => Redacted.make(v)), Effect.orElseSucceed(() => null), ); }), - Effect.catch(() => Effect.succeed(null as string | null)), + Effect.catch(() => Effect.succeed | null>(null)), ), list: (): Effect.Effect => diff --git a/packages/plugins/openapi/src/sdk/backing.ts b/packages/plugins/openapi/src/sdk/backing.ts index 4e6e5ba95..6c3454c89 100644 --- a/packages/plugins/openapi/src/sdk/backing.ts +++ b/packages/plugins/openapi/src/sdk/backing.ts @@ -1,4 +1,4 @@ -import { Effect, Option, Schema } from "effect"; +import { Effect, Option, Redacted, Schema } from "effect"; import type { Layer } from "effect"; import { HttpClient } from "effect/unstable/http"; @@ -12,6 +12,7 @@ import { sortHealthCheckCandidatesByIdentity, extractIdentity, extractResponseFields, + makeCredentialScrubber, projectResponseFields, type HealthCheckCandidate, type HealthCheckResponseField, @@ -678,7 +679,8 @@ export const invokeOpenApiBackedTool = (input: { if (template) { const missing = requiredTemplateVariables(template).filter((name) => { const value = input.credential.values[name]; - return value == null || value === ""; + // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: emptiness check only; the wrapper cannot answer it + return value == null || Redacted.value(value) === ""; }); if (missing.length > 0) { return openApiAuthToolFailure({ @@ -696,6 +698,13 @@ export const invokeOpenApiBackedTool = (input: { Object.assign(queryParams, rendered.queryParams); } + // An upstream error body or transport failure can echo the request back + // (the URL with its query string, the rejected Authorization header), so + // every message and raw `details` payload leaving this path is scrubbed of + // the values it authenticated with. Same guarantee the health probe below + // makes; both build the scrubber from the same resolved credential. + const scrub = makeCredentialScrubber(input.credential.values); + const invocation = yield* invokeWithLayer( binding, (input.args ?? {}) as Record, @@ -712,8 +721,8 @@ export const invokeOpenApiBackedTool = (input: { ok: false as const, failure: ToolResult.fail({ code: "upstream_response_headers_timeout", - message: error.message, - details: error.cause ?? error, + message: scrub.text(error.message), + details: scrub.payload(error.cause ?? error), }), }) : error.reason === "response_body_timeout" @@ -721,8 +730,8 @@ export const invokeOpenApiBackedTool = (input: { ok: false as const, failure: ToolResult.fail({ code: "upstream_response_body_timeout", - message: error.message, - details: error.cause ?? error, + message: scrub.text(error.message), + details: scrub.payload(error.cause ?? error), }), }) : Effect.fail(error), @@ -767,7 +776,7 @@ export const invokeOpenApiBackedTool = (input: { connection: String(input.credential.connection), credentialKind: "oauth", credentialLabel: "Upstream authorization", - details: result.error, + details: scrub.payload(result.error), }); } return openApiAuthToolFailure({ @@ -779,14 +788,14 @@ export const invokeOpenApiBackedTool = (input: { connection: String(input.credential.connection), credentialKind: "upstream", credentialLabel: "Upstream authorization", - details: result.error, + details: scrub.payload(result.error), }); } return ToolResult.fail({ code: "upstream_http_error", status: result.status, - message: extractOpenApiUpstreamMessage(result.error, result.status), - details: result.error, + message: scrub.text(extractOpenApiUpstreamMessage(result.error, result.status)), + details: scrub.payload(result.error), }); } return ToolResult.ok(result.data, { @@ -925,7 +934,8 @@ export const checkHealthOpenApi = (input: { if (template) { const missing = requiredTemplateVariables(template).filter((name) => { const value = input.credential.values[name]; - return value == null || value === ""; + // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: emptiness check only; the wrapper cannot answer it + return value == null || Redacted.value(value) === ""; }); if (missing.length > 0) { return { @@ -957,17 +967,13 @@ export const checkHealthOpenApi = (input: { // Upstream error text can echo the request back (URLs with query params, // auth headers), so scrub every credential value out of anything that leaves // as `detail` so a probe can never leak the secret it authenticated with. - const secretValues = Object.values(input.credential.values).filter( - (value): value is string => typeof value === "string" && value.length > 0, - ); - const scrubSecrets = (text: string): string => - secretValues.reduce((out, secret) => out.split(secret).join("[redacted]"), text); + const scrub = makeCredentialScrubber(input.credential.values); if (!probe.ok) { return { status: "degraded", checkedAt, - detail: scrubSecrets(`Health check request failed: ${probe.failure.message}`), + detail: scrub.text(`Health check request failed: ${probe.failure.message}`), } satisfies HealthCheckResult; } @@ -988,7 +994,7 @@ export const checkHealthOpenApi = (input: { ...(status === "healthy" ? {} : { - detail: scrubSecrets( + detail: scrub.text( extractOpenApiUpstreamMessage(probe.result.error, probe.result.status), ), }), diff --git a/packages/plugins/openapi/src/sdk/config.ts b/packages/plugins/openapi/src/sdk/config.ts index 78c639f4a..b0635a652 100644 --- a/packages/plugins/openapi/src/sdk/config.ts +++ b/packages/plugins/openapi/src/sdk/config.ts @@ -1,7 +1,8 @@ -import { Option, Schema } from "effect"; +import { Option, Schema, type Redacted } from "effect"; import { ApiKeyAuthMethod, TOKEN_VARIABLE, + oauthBearerPlacement, renderAuthPlacements, requiredPlacementVariables, } from "@executor-js/sdk/http-auth"; @@ -100,13 +101,17 @@ export interface RenderedAuth { * different value. */ export const renderAuthTemplate = ( template: Authentication, - values: Record, + values: Record | null>, ): RenderedAuth => { if (template.kind === "oauth2") { - return { - headers: { authorization: `Bearer ${values[TOKEN_VARIABLE] ?? ""}` }, - queryParams: {}, - }; + // An oauth2 template declares no placements, so route it through the + // canonical bearer placement rather than interpolating the token here: + // interpolating a `Redacted` renders the literal "" onto the + // wire, and the failure is a 401 with no trace back to this line. + // An unresolved token yields no header at all — callers gate on + // `requiredTemplateVariables` and fail with `oauth_connection_missing` + // before reaching here, so an empty `Bearer ` must never be sent. + return renderAuthPlacements([oauthBearerPlacement("authorization")], values); } return renderAuthPlacements(template.placements, values); }; diff --git a/packages/plugins/openapi/src/sdk/invoke-telemetry.test.ts b/packages/plugins/openapi/src/sdk/invoke-telemetry.test.ts new file mode 100644 index 000000000..389b5c136 --- /dev/null +++ b/packages/plugins/openapi/src/sdk/invoke-telemetry.test.ts @@ -0,0 +1,87 @@ +// --------------------------------------------------------------------------- +// The invoke span must not carry the connection's base URL verbatim. +// +// A connection `baseUrl` is a user-supplied URL from the same class as the mcp +// and graphql endpoints already covered: `?token=…` in the query string and +// `user:pass@host` userinfo are both supported input shapes. Stamping it raw on +// `plugin.openapi.invoke` shipped the credential to the trace backend on every +// tool call. Synthetic placeholders only. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option, Tracer } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; + +import { invokeWithLayer } from "./invoke"; +import { OperationBinding } from "./types"; + +const QUERY_TOKEN = "synthetic-endpoint-token"; +const USERINFO_PASSWORD = "synthetic-endpoint-password"; + +/** Records every span the program opens so the stamped attributes can be read + * back. Port 1 connection-refuses immediately, so the invocation resolves + * without any network dependency. */ +const recordingTracer = (spans: Array) => + Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + context: (primitive, fiber) => primitive["~effect/Effect/evaluate"](fiber), + }); + +const operation = OperationBinding.make({ + method: "get", + servers: [], + pathTemplate: "/things", + requestBody: Option.none(), + responseBody: Option.none(), + parameters: [], +}); + +const invokeAgainst = (baseUrl: string) => + Effect.gen(function* () { + const spans: Array = []; + yield* invokeWithLayer(operation, {}, baseUrl, {}, {}, FetchHttpClient.layer).pipe( + Effect.exit, + Effect.provideService(Tracer.Tracer, recordingTracer(spans)), + ); + return spans; + }); + +describe("openapi invoke telemetry", () => { + it.effect("stamps a sanitized base_url on the invoke span", () => + Effect.gen(function* () { + const spans = yield* invokeAgainst( + `http://svc-user:${USERINFO_PASSWORD}@127.0.0.1:1/v1?token=${QUERY_TOKEN}`, + ); + + const invoke = spans.find((span) => span.name === "plugin.openapi.invoke"); + expect(invoke).toBeDefined(); + expect(invoke?.attributes.get("plugin.openapi.base_url")).toBe("http://127.0.0.1:1/v1"); + + // Scoped to the plugin's own spans. Effect's HttpClient separately stamps + // `url.full`/`url.query` on its outgoing client spans; those are scrubbed + // downstream by the export pipeline's redaction, which is not installed + // at this level. + const serialized = JSON.stringify( + spans + .filter((span) => span.name.startsWith("plugin.openapi.")) + .map((span) => Object.fromEntries(span.attributes.entries())), + ); + expect(serialized).not.toContain(QUERY_TOKEN); + expect(serialized).not.toContain(USERINFO_PASSWORD); + }), + ); + + it.effect("leaves a credential-free base URL intact", () => + Effect.gen(function* () { + const spans = yield* invokeAgainst("http://127.0.0.1:1/v1"); + + const invoke = spans.find((span) => span.name === "plugin.openapi.invoke"); + expect(invoke?.attributes.get("plugin.openapi.base_url")).toBe("http://127.0.0.1:1/v1"); + expect(invoke?.attributes.get("plugin.openapi.path_template")).toBe("/things"); + }), + ); +}); diff --git a/packages/plugins/openapi/src/sdk/invoke.ts b/packages/plugins/openapi/src/sdk/invoke.ts index 4831eafa0..63ab4da09 100644 --- a/packages/plugins/openapi/src/sdk/invoke.ts +++ b/packages/plugins/openapi/src/sdk/invoke.ts @@ -1,6 +1,6 @@ import { Effect, Exit, Fiber, Layer, Option, Schema, Stream } from "effect"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; -import type { ToolFileValue } from "@executor-js/sdk/core"; +import { endpointForTelemetry, type ToolFileValue } from "@executor-js/sdk/core"; import { OpenApiInvocationError } from "./errors"; import { isNdjsonMediaType, NDJSON_MEDIA_TYPES, resolveServerUrl } from "./openapi-utils"; @@ -1293,7 +1293,10 @@ export const invokeWithLayer = ( attributes: { "plugin.openapi.method": operation.method.toUpperCase(), "plugin.openapi.path_template": operation.pathTemplate, - "plugin.openapi.base_url": effectiveBaseUrl, + // The connection's `baseUrl` is a user-supplied URL from the same class + // as the mcp/graphql endpoints: it may carry `?token=…` or `user:pass@` + // userinfo. Sanitize before stamping. + "plugin.openapi.base_url": endpointForTelemetry(effectiveBaseUrl), }, }), ); diff --git a/packages/plugins/openapi/src/sdk/migrate-config.test.ts b/packages/plugins/openapi/src/sdk/migrate-config.test.ts index 15956cac0..60c32f887 100644 --- a/packages/plugins/openapi/src/sdk/migrate-config.test.ts +++ b/packages/plugins/openapi/src/sdk/migrate-config.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { Redacted } from "effect"; import { renderAuthTemplate } from "./config"; import { migrateOpenApiAuthConfig } from "./migrate-config"; @@ -172,7 +173,7 @@ describe("legacy render equivalence", () => { type: "apiKey", headers: { Authorization: ["Bearer ", { type: "variable", name: "token" }] }, }); - expect(renderAuthTemplate(template, { token: "tok_1" })).toEqual({ + expect(renderAuthTemplate(template, { token: Redacted.make("tok_1") })).toEqual({ headers: { Authorization: "Bearer tok_1" }, queryParams: {}, }); @@ -189,7 +190,11 @@ describe("legacy render equivalence", () => { queryParams: { team: [{ type: "variable", name: "team" }] }, }); expect( - renderAuthTemplate(template, { dd_api_key: "a", dd_application_key: "b", team: "t" }), + renderAuthTemplate(template, { + dd_api_key: Redacted.make("a"), + dd_application_key: Redacted.make("b"), + team: Redacted.make("t"), + }), ).toEqual({ headers: { "DD-API-KEY": "a", "DD-APPLICATION-KEY": "b" }, queryParams: { team: "t" }, @@ -205,7 +210,7 @@ describe("legacy render equivalence", () => { Authorization: ["Bearer ", { type: "variable", name: "token" }], }, }); - expect(renderAuthTemplate(template, { token: "tok_1" })).toEqual({ + expect(renderAuthTemplate(template, { token: Redacted.make("tok_1") })).toEqual({ headers: { "X-Api-Version": "2023-01-01", Authorization: "Bearer tok_1" }, queryParams: {}, }); @@ -217,7 +222,7 @@ describe("legacy render equivalence", () => { type: "apiKey", queryParams: { api_key: [{ type: "variable", name: "token" }] }, }); - expect(renderAuthTemplate(template, { token: "k1" })).toEqual({ + expect(renderAuthTemplate(template, { token: Redacted.make("k1") })).toEqual({ headers: {}, queryParams: { api_key: "k1" }, }); diff --git a/packages/plugins/openapi/src/sdk/redacted-credential.test.ts b/packages/plugins/openapi/src/sdk/redacted-credential.test.ts new file mode 100644 index 000000000..ef246d1be --- /dev/null +++ b/packages/plugins/openapi/src/sdk/redacted-credential.test.ts @@ -0,0 +1,123 @@ +// --------------------------------------------------------------------------- +// The wire half of the `Redacted` credential contract, asserted against a real +// server rather than against types. +// +// A `Redacted` that never unwraps is not "safe", it is broken: the wrapper's +// toString/toJSON render the literal "", so a missed unwrap does not +// throw — it sends that literal upstream and comes back as a 401 with nothing +// pointing at the cause. This asserts the SECRET reached the wire, byte for +// byte, through the rendering path every HTTP plugin shares. +// +// The other half (the credential a plugin receives still serializes redacted) +// lives in `packages/core/sdk/src/redacted-credential.test.ts`, where the +// plugin contract itself is defined. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { FetchHttpClient, HttpServerRequest } from "effect/unstable/http"; + +import { + createExecutor, + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + ToolAddress, +} from "@executor-js/sdk"; +import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; +import { + serveOpenApiHttpApiTestServer, + unwrapInvocation, +} from "@executor-js/plugin-openapi/testing"; + +import { openApiPlugin } from "./plugin"; +import { type AuthenticationInput } from "./types"; + +// Synthetic throughout: not shaped like any real provider's keys, and the +// assertions are exact-match, so a redacted rendering cannot pass. +const API_SECRET = "synthetic-apikey-value"; +const TEAM_SECRET = "synthetic-team-value"; + +const EchoHeaders = Schema.Struct({ + authorization: Schema.optional(Schema.String), + "x-team": Schema.optional(Schema.String), +}); + +const EchoGroup = HttpApiGroup.make("echo").add( + HttpApiEndpoint.get("headers", "/echo", { success: EchoHeaders }), +); +const EchoApi = HttpApi.make("echoApi").add(EchoGroup); + +const EchoGroupLive = HttpApiBuilder.group(EchoApi, "echo", (handlers) => + handlers.handle("headers", () => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + return EchoHeaders.make({ + authorization: request.headers["authorization"], + "x-team": request.headers["x-team"], + }); + }), + ), +); + +const serveEcho = () => + serveOpenApiHttpApiTestServer({ api: EchoApi, handlersLayer: EchoGroupLive }); + +// Two placements over two DISTINCT inputs: proves each variable is unwrapped +// against its own entry, not that one lucky value leaked through everywhere. +const twoInputTemplate: AuthenticationInput = { + slug: AuthTemplateSlug.make("api_key"), + type: "apiKey", + headers: { + authorization: ["Bearer ", { type: "variable" as const, name: "token" }], + "x-team": [{ type: "variable" as const, name: "team" }], + }, +}; + +const INTEG = IntegrationSlug.make("echo_api"); +const TEMPLATE = AuthTemplateSlug.make("api_key"); +const ECHO = "echo.headers"; + +describe("Redacted credentials on the wire", () => { + it.effect("every rendered placement carries the real secret, not the wrapper", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveEcho(); + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [ + openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), + memoryCredentialsPlugin(), + ] as const, + }), + ); + + yield* executor.openapi.addSpec({ + spec: { kind: "blob", value: server.specJson }, + slug: "echo_api", + baseUrl: server.baseUrl, + authenticationTemplate: [twoInputTemplate], + }); + + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + values: { token: API_SECRET, team: TEAM_SECRET }, + }); + + const echoed = unwrapInvocation( + yield* executor.execute(ToolAddress.make(`tools.echo_api.org.main.${ECHO}`), {}), + ).data as { authorization?: string; "x-team"?: string }; + + expect(echoed.authorization).toBe(`Bearer ${API_SECRET}`); + expect(echoed["x-team"]).toBe(TEAM_SECRET); + // The failure mode this test exists for: a missed unwrap sends the + // wrapper's rendering, and the request still leaves the process. + expect(echoed.authorization).not.toContain(""); + }), + ), + ); +}); diff --git a/packages/plugins/openapi/src/sdk/response-body-timeout.test.ts b/packages/plugins/openapi/src/sdk/response-body-timeout.test.ts index 08441afd2..ceeb080c7 100644 --- a/packages/plugins/openapi/src/sdk/response-body-timeout.test.ts +++ b/packages/plugins/openapi/src/sdk/response-body-timeout.test.ts @@ -141,6 +141,15 @@ describe("OpenAPI response body timeout", () => { error: { code: "upstream_response_body_timeout", message: expect.stringContaining("response body"), + // The failure's `message` is non-enumerable, so the credential + // scrub has to project it explicitly or `details` arrives as a bag + // holding only `statusCode`/`reason` and the timeout loses its + // diagnostic. + details: { + _tag: "OpenApiInvocationError", + reason: "response_body_timeout", + message: expect.stringContaining("response body"), + }, }, }); }), diff --git a/packages/plugins/openapi/src/sdk/response-headers-timeout.test.ts b/packages/plugins/openapi/src/sdk/response-headers-timeout.test.ts index b23148ff0..fcc2eeed5 100644 --- a/packages/plugins/openapi/src/sdk/response-headers-timeout.test.ts +++ b/packages/plugins/openapi/src/sdk/response-headers-timeout.test.ts @@ -156,6 +156,14 @@ describe("OpenAPI response headers timeout", () => { error: { code: "upstream_response_headers_timeout", message: expect.stringContaining("Upstream returned no response headers within 100ms"), + // The failure's `message` is non-enumerable, so the credential scrub + // has to project it explicitly or `details` arrives as a bag holding + // only `statusCode`/`reason` and the timeout loses its diagnostic. + details: { + _tag: "OpenApiInvocationError", + reason: "response_headers_timeout", + message: expect.stringContaining("Upstream returned no response headers within 100ms"), + }, }, }); }), diff --git a/packages/plugins/workos-vault/src/sdk/secret-store.test.ts b/packages/plugins/workos-vault/src/sdk/secret-store.test.ts index 91281dd78..396ecd093 100644 --- a/packages/plugins/workos-vault/src/sdk/secret-store.test.ts +++ b/packages/plugins/workos-vault/src/sdk/secret-store.test.ts @@ -1,6 +1,6 @@ // oxlint-disable executor/no-try-catch-or-throw -- boundary: the fake WorkOS Vault client below simulates the real promise SDK, which throws to signal API errors import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import { Owner, @@ -59,11 +59,19 @@ const makeMetadata = ( versionId, }); +/** `objects` is the vault's storage as the fake holds it — the bytes a real + * Vault would have received. Assertions about what was actually persisted read + * it directly rather than round-tripping through `get`, which would hide a + * write that stored the wrong thing. */ +type InspectableFakeClient = WorkOSVaultClient & { + readonly objects: ReadonlyMap; +}; + const makeFakeClient = (options?: { readonly conflictOnNextSecretUpdate?: boolean; readonly rejectNamesWithColon?: boolean; readonly rejectReadNamesLongerThan?: number; -}): WorkOSVaultClient => { +}): InspectableFakeClient => { const objects = new Map(); let sequence = 0; let conflictPending = options?.conflictOnNextSecretUpdate ?? false; @@ -161,6 +169,7 @@ const makeFakeClient = (options?: { }; return { + objects, use: (operation: string, fn: (client: WorkOSVaultPromiseApi) => Promise) => Effect.tryPromise({ try: () => fn(rawClient), @@ -246,7 +255,7 @@ const makeFakeStorageDeps = (binding: OwnerBinding): StorageDeps => { const orgBinding: OwnerBinding = { tenant: Tenant.make("tenant-a"), subject: null }; const makeProvider = ( - client: WorkOSVaultClient, + client: InspectableFakeClient, binding: OwnerBinding = orgBinding, ): ReturnType => { const deps = makeFakeStorageDeps(binding); @@ -256,6 +265,13 @@ const makeProvider = ( const id = (value: string) => ProviderItemId.make(value); +/** Unwrap a `get` for comparison against the expected plaintext. Absence stays + * `null`, tested explicitly. */ +const resolved = ( + effect: Effect.Effect | null, E>, +): Effect.Effect => + effect.pipe(Effect.map((value) => (value === null ? null : Redacted.value(value)))); + describe("WorkOS Vault credential provider", () => { it.effect("stores and resolves values through WorkOS Vault", () => Effect.gen(function* () { @@ -263,7 +279,7 @@ describe("WorkOS Vault credential provider", () => { yield* provider.set!(id("github-token"), "ghp_secret"); - expect(yield* provider.get(id("github-token"))).toBe("ghp_secret"); + expect(yield* resolved(provider.get(id("github-token")))).toBe("ghp_secret"); expect(provider.key).toBe("workos-vault"); const listed = yield* provider.list!(); @@ -272,6 +288,29 @@ describe("WorkOS Vault credential provider", () => { }), ); + // The whole point of the Redacted migration: a `set` that forgot to unwrap + // would send the literal "" to Vault, and every read-back through + // `get` would still look right. Only the vault's own bytes catch it. + it.effect("writes the real secret to Vault for both string and Redacted input", () => + Effect.gen(function* () { + const client = makeFakeClient(); + const provider = makeProvider(client); + + yield* provider.set!(id("plain"), "sk_plain_written"); + yield* provider.set!(id("wrapped"), Redacted.make("sk_wrapped_written")); + + const persisted = [...client.objects.values()].map((object) => object.value); + expect(persisted).toEqual(expect.arrayContaining(["sk_plain_written", "sk_wrapped_written"])); + expect(persisted).not.toContain(""); + + // An update path (create → update) must unwrap too. + yield* provider.set!(id("wrapped"), Redacted.make("sk_wrapped_rotated")); + const rotated = [...client.objects.values()].map((object) => object.value); + expect(rotated).toContain("sk_wrapped_rotated"); + expect(rotated).not.toContain(""); + }), + ); + it.effect("updates values in place", () => Effect.gen(function* () { const provider = makeProvider(makeFakeClient()); @@ -279,7 +318,7 @@ describe("WorkOS Vault credential provider", () => { yield* provider.set!(id("api-key"), "v1"); yield* provider.set!(id("api-key"), "v2"); - expect(yield* provider.get(id("api-key"))).toBe("v2"); + expect(yield* resolved(provider.get(id("api-key")))).toBe("v2"); expect(yield* provider.list!()).toHaveLength(1); }), ); @@ -287,7 +326,7 @@ describe("WorkOS Vault credential provider", () => { it.effect("get returns null for an unknown id", () => Effect.gen(function* () { const provider = makeProvider(makeFakeClient()); - expect(yield* provider.get(id("absent"))).toBeNull(); + expect(yield* resolved(provider.get(id("absent")))).toBeNull(); }), ); @@ -305,11 +344,11 @@ describe("WorkOS Vault credential provider", () => { const provider = makeProvider(makeFakeClient()); yield* provider.set!(id("remove-me"), "gone soon"); - expect(yield* provider.get(id("remove-me"))).toBe("gone soon"); + expect(yield* resolved(provider.get(id("remove-me")))).toBe("gone soon"); yield* provider.delete!(id("remove-me")); - expect(yield* provider.get(id("remove-me"))).toBeNull(); + expect(yield* resolved(provider.get(id("remove-me")))).toBeNull(); expect(yield* provider.list!()).toHaveLength(0); // delete is idempotent and returns void; deleting an absent id is a no-op. @@ -329,7 +368,7 @@ describe("WorkOS Vault credential provider", () => { yield* provider.set!(longId, "token"); // The metadata row exists; the vault value read is treated as missing. - expect(yield* provider.get(longId)).toBeNull(); + expect(yield* resolved(provider.get(longId))).toBeNull(); yield* provider.delete!(longId); expect(yield* provider.list!()).toHaveLength(0); @@ -343,7 +382,7 @@ describe("WorkOS Vault credential provider", () => { yield* provider.set!(id("conflict"), "initial"); yield* provider.set!(id("conflict"), "retry-me"); - expect(yield* provider.get(id("conflict"))).toBe("retry-me"); + expect(yield* resolved(provider.get(id("conflict")))).toBe("retry-me"); expect((yield* provider.list!()).map((s) => s.id)).toEqual(["conflict"]); }), ); @@ -355,7 +394,7 @@ describe("WorkOS Vault credential provider", () => { const provider = makeProvider(makeFakeClient({ rejectNamesWithColon: true })); yield* provider.set!(id("user-org:u1:org42"), "personal"); - expect(yield* provider.get(id("user-org:u1:org42"))).toBe("personal"); + expect(yield* resolved(provider.get(id("user-org:u1:org42")))).toBe("personal"); }), ); @@ -368,7 +407,7 @@ describe("WorkOS Vault credential provider", () => { const provider = makeProvider(makeFakeClient(), userBinding); yield* provider.set!(id("token"), "v"); - expect(yield* provider.get(id("token"))).toBe("v"); + expect(yield* resolved(provider.get(id("token")))).toBe("v"); }), ); }); @@ -495,9 +534,9 @@ describe("WorkOS Vault — credential owner partitioning", () => { yield* creator.set!(id("connection:org:exa_search_api:workspaceexa:token"), "exa_secret"); // user B (same org, different subject) resolves the same org connection - expect(yield* other.get(id("connection:org:exa_search_api:workspaceexa:token"))).toBe( - "exa_secret", - ); + expect( + yield* resolved(other.get(id("connection:org:exa_search_api:workspaceexa:token"))), + ).toBe("exa_secret"); // …and an automation context with no subject resolves it too const automation = makeWorkOSVaultCredentialProvider({ client, @@ -506,9 +545,9 @@ describe("WorkOS Vault — credential owner partitioning", () => { ), owner: { tenant: Tenant.make("tenant-a"), subject: null }, }); - expect(yield* automation.get(id("connection:org:exa_search_api:workspaceexa:token"))).toBe( - "exa_secret", - ); + expect( + yield* resolved(automation.get(id("connection:org:exa_search_api:workspaceexa:token"))), + ).toBe("exa_secret"); }), ); @@ -530,8 +569,10 @@ describe("WorkOS Vault — credential owner partitioning", () => { yield* userA.set!(id("connection:user:notion:personal:token"), "private_secret"); // user A resolves their own; user B cannot see the metadata → no value - expect(yield* userA.get(id("connection:user:notion:personal:token"))).toBe("private_secret"); - expect(yield* userB.get(id("connection:user:notion:personal:token"))).toBeNull(); + expect(yield* resolved(userA.get(id("connection:user:notion:personal:token")))).toBe( + "private_secret", + ); + expect(yield* resolved(userB.get(id("connection:user:notion:personal:token")))).toBeNull(); }), ); @@ -553,8 +594,8 @@ describe("WorkOS Vault — credential owner partitioning", () => { yield* creator.set!(id("oauth:org:slack:workspace"), "access_tok"); yield* creator.set!(id("oauth-client:org:my_app:secret"), "client_sec"); - expect(yield* other.get(id("oauth:org:slack:workspace"))).toBe("access_tok"); - expect(yield* other.get(id("oauth-client:org:my_app:secret"))).toBe("client_sec"); + expect(yield* resolved(other.get(id("oauth:org:slack:workspace")))).toBe("access_tok"); + expect(yield* resolved(other.get(id("oauth-client:org:my_app:secret")))).toBe("client_sec"); }), ); }); @@ -589,8 +630,8 @@ describe("WorkOS Vault — object-name partition isolation", () => { yield* tenantOne.set!(sharedId, "org-1-key"); yield* tenantTwo.set!(sharedId, "org-2-key"); - expect(yield* tenantOne.get(sharedId)).toBe("org-1-key"); - expect(yield* tenantTwo.get(sharedId)).toBe("org-2-key"); + expect(yield* resolved(tenantOne.get(sharedId))).toBe("org-1-key"); + expect(yield* resolved(tenantTwo.get(sharedId))).toBe("org-2-key"); }), ); @@ -604,8 +645,8 @@ describe("WorkOS Vault — object-name partition isolation", () => { yield* userA.set!(sharedId, "a-token"); yield* userB.set!(sharedId, "b-token"); - expect(yield* userA.get(sharedId)).toBe("a-token"); - expect(yield* userB.get(sharedId)).toBe("b-token"); + expect(yield* resolved(userA.get(sharedId))).toBe("a-token"); + expect(yield* resolved(userB.get(sharedId))).toBe("b-token"); }), ); @@ -645,13 +686,13 @@ describe("WorkOS Vault — object-name partition isolation", () => { // resolves (name under the limit), hashed siblings stay distinct, and // identical long ids in two partitions stay isolated - expect(yield* userA.get(longId)).toBe("a-refresh-token"); - expect(yield* userA.get(siblingId)).toBe("a-access-token"); - expect(yield* userB.get(longId)).toBe("b-refresh-token"); + expect(yield* resolved(userA.get(longId))).toBe("a-refresh-token"); + expect(yield* resolved(userA.get(siblingId))).toBe("a-access-token"); + expect(yield* resolved(userB.get(longId))).toBe("b-refresh-token"); // a second write lands on the same capped name (update, not duplicate) yield* userA.set!(longId, "a-refresh-token-2"); - expect(yield* userA.get(longId)).toBe("a-refresh-token-2"); + expect(yield* resolved(userA.get(longId))).toBe("a-refresh-token-2"); }), ); }); diff --git a/packages/plugins/workos-vault/src/sdk/secret-store.ts b/packages/plugins/workos-vault/src/sdk/secret-store.ts index 73e31e7ee..5eee7737b 100644 --- a/packages/plugins/workos-vault/src/sdk/secret-store.ts +++ b/packages/plugins/workos-vault/src/sdk/secret-store.ts @@ -1,6 +1,7 @@ -import { Effect, Option, Predicate, Schema } from "effect"; +import { Effect, Option, Predicate, Redacted, Schema } from "effect"; import { + credentialValueToWrite, type CredentialProvider, Owner, type OwnerBinding, @@ -381,18 +382,18 @@ export const makeWorkOSVaultCredentialProvider = ( ), ); if (!object || !object.value) return null; - return object.value; + return Redacted.make(object.value); }), has: (id: ProviderItemId) => store.get(id).pipe(Effect.map((meta) => meta !== null)), - set: (id: ProviderItemId, value: string) => + set: (id: ProviderItemId, value: string | Redacted.Redacted) => Effect.gen(function* () { const existing = yield* store.get(id); yield* upsertSecretValue( client, yield* nameFor(id), - value, + credentialValueToWrite(value), vaultContextFor(id, owner), ).pipe( Effect.mapError( diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx index 0f8ff57f9..8468a59f0 100644 --- a/packages/react/src/api/atoms.tsx +++ b/packages/react/src/api/atoms.tsx @@ -16,6 +16,7 @@ import { import * as Atom from "effect/unstable/reactivity/Atom"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import * as Effect from "effect/Effect"; +import type * as Redacted from "effect/Redacted"; import { ExecutorApiClient } from "./client"; import { connectionWriteKeys, ReactivityKey } from "./reactivity-keys"; @@ -314,7 +315,9 @@ export const connectionsForIntegrationAtom = Atom.family( // The connection-create payload mirrors `CreateConnectionPayload` from the core // API: the common fields plus exactly one value origin — a single pasted `value` // (the `token` input), a `values` map (one per named input, e.g. Datadog's two -// keys), or an external `from` reference. +// keys), or an external `from` reference. Pasted secrets are `Redacted` on this +// side too: the payload schema unwraps them while encoding the request body, so +// the credential is never a bare string in browser state. type CreateConnectionArg = { readonly payload: { readonly owner: Owner; @@ -324,8 +327,8 @@ type CreateConnectionArg = { readonly identityLabel?: string | null; readonly description?: string | null; } & ( - | { readonly value: string } - | { readonly values: Record } + | { readonly value: Redacted.Redacted } + | { readonly values: Record> } | { readonly from: { readonly provider: ProviderKey; diff --git a/packages/react/src/api/client.tsx b/packages/react/src/api/client.tsx index bdb0fc455..5a1a31ef5 100644 --- a/packages/react/src/api/client.tsx +++ b/packages/react/src/api/client.tsx @@ -4,6 +4,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/ import * as HttpClientError from "effect/unstable/http/HttpClientError"; import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; import { ExecutorApi } from "@executor-js/api/client"; +import { RedactedHeaderNamesLive } from "@executor-js/sdk/http-auth"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -11,6 +12,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { reportHandledFrontendError } from "./error-reporting"; +import { layerRedacted } from "./otlp-redaction"; import { notifyLocalAuthRequired } from "./local-auth"; import { EXECUTOR_ORG_HEADER, @@ -105,8 +107,20 @@ if (otlpTracesUrl && typeof document !== "undefined" && Math.random() < otlpSamp // Browser sessions are short; the 5s default loses the tail spans // when the tab closes. exportInterval: "1 second", - }).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer)), + }).pipe( + // The browser's spans go to the same Axiom dataset the worker's do, so + // they get the same scrub: HttpClient stamps `url.full`/`url.query` on + // every API request, and a failed span carries the interpolated message + // and cause as event text. Wrapping the serializer is the browser's + // equivalent of the worker's outermost span processor. + Layer.provide(layerRedacted(OtlpSerialization.layerJson)), + Layer.provide(FetchHttpClient.layer), + ), Layer.succeed(HttpClient.TracerDisabledWhen, (request) => request.url.includes("/v1/traces")), + // Widen the header names the tracer wraps: an integration's API key rides + // on whatever header its auth placement names, and Effect's default list + // is only authorization/cookie/set-cookie/x-api-key. + RedactedHeaderNamesLive, ), ); } diff --git a/packages/react/src/api/otlp-redaction.test.ts b/packages/react/src/api/otlp-redaction.test.ts new file mode 100644 index 000000000..b4af9814c --- /dev/null +++ b/packages/react/src/api/otlp-redaction.test.ts @@ -0,0 +1,130 @@ +// --------------------------------------------------------------------------- +// The BROWSER export seam. The web client ships spans to /v1/traces, which the +// worker forwards to the same Axiom dataset the worker's own spans land in, so +// the browser must apply the same scrub — the redaction POLICY is covered by +// `packages/core/sdk/src/span-redaction.test.ts`. +// +// This drives the real `OtlpTracer` (the layer `client.tsx` installs) with the +// redacting serializer in front of a fake fetch, and asserts on the bytes that +// would have gone over the wire. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; +import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; + +import { layerRedacted } from "./otlp-redaction"; + +// Synthetic placeholders only — never a real authorization code or token. +const CODE = "synthetic-authorization-code"; +const TOKEN = "synthetic-endpoint-token"; + +const EXPORT_URL = "https://app.test/v1/traces"; + +/** Runs `program` under a real `OtlpTracer` whose exporter posts through a fake + * fetch, and returns every exported body. The exporter flushes from a scope + * finalizer, so closing the scope drains the batch. */ +const exportedBodies = ( + program: Effect.Effect, + serialization: Layer.Layer, +): Effect.Effect => + Effect.gen(function* () { + const decoder = new TextDecoder(); + const bodies: string[] = []; + const fetchLayer = Layer.succeed(FetchHttpClient.Fetch)((( + _input: RequestInfo | URL, + init?: RequestInit, + ) => { + // `OtlpSerialization.layerJson` produces an `HttpBody.Uint8Array`, so the + // wire bytes arrive already encoded. + const body = init?.body; + bodies.push(body instanceof Uint8Array ? decoder.decode(body) : String(body ?? "")); + return Promise.resolve(new Response(null, { status: 200 })); + }) as typeof globalThis.fetch); + + yield* Effect.scoped( + Effect.provide( + program, + OtlpTracer.layer({ + url: EXPORT_URL, + resource: { serviceName: "executor-web-test" }, + exportInterval: "1 minute", + }).pipe( + Layer.provide(serialization), + Layer.provide(FetchHttpClient.layer.pipe(Layer.provide(fetchLayer))), + ), + ), + ); + + return bodies; + }); + +/** One span carrying the attributes `HttpClient` stamps on every API request. */ +const callbackSpan = Effect.void.pipe( + Effect.withSpan("http.client GET", { + attributes: { + "url.full": `https://app.test/api/oauth/callback?code=${CODE}&domain=example.test`, + "url.query": `code=${CODE}&domain=example.test`, + "url.path": "/api/oauth/callback", + }, + }), +); + +describe("layerRedacted", () => { + it.effect("scrubs url.full and url.query out of the exported payload", () => + Effect.gen(function* () { + const bodies = yield* exportedBodies( + callbackSpan, + layerRedacted(OtlpSerialization.layerJson), + ); + + expect(bodies).toHaveLength(1); + expect(bodies[0]).not.toContain(CODE); + // Route-level visibility survives, and the stripped keys are recorded. + expect(bodies[0]).toContain("/api/oauth/callback"); + expect(bodies[0]).toContain("url.query.stripped_keys"); + }), + ); + + it.effect("the unwrapped serializer exports the code — the wrapper is load-bearing", () => + Effect.gen(function* () { + const bodies = yield* exportedBodies(callbackSpan, OtlpSerialization.layerJson); + + expect(bodies[0]).toContain(CODE); + }), + ); + + it.effect("scrubs a failed span's exception event and status message", () => + Effect.gen(function* () { + // A failing span: `OtlpTracer` renders the cause into an `exception` + // event AND into `status.message`, neither of which is a URL attribute. + const failing = Effect.fail( + // oxlint-disable-next-line executor/no-error-constructor -- the failure text is what is under test; a tagged error would hide it + new Error(`POST https://mcp.test/mcp?token=${TOKEN} failed with 401`), + ).pipe(Effect.withSpan("http.client POST"), Effect.ignore); + + const bodies = yield* exportedBodies(failing, layerRedacted(OtlpSerialization.layerJson)); + + expect(bodies[0]).not.toContain(TOKEN); + expect(bodies[0]).toContain("https://mcp.test/mcp"); + expect(bodies[0]).toContain("failed with 401"); + }), + ); + + it.effect("leaves a span with nothing sensitive untouched", () => + Effect.gen(function* () { + const bodies = yield* exportedBodies( + Effect.void.pipe( + Effect.withSpan("http.client GET", { + attributes: { "url.full": "https://app.test/api/integrations?owner=org" }, + }), + ), + layerRedacted(OtlpSerialization.layerJson), + ); + + expect(bodies[0]).toContain("https://app.test/api/integrations?owner=org"); + expect(bodies[0]).not.toContain("url.query.stripped_keys"); + }), + ); +}); diff --git a/packages/react/src/api/otlp-redaction.ts b/packages/react/src/api/otlp-redaction.ts new file mode 100644 index 000000000..6d1f31c1d --- /dev/null +++ b/packages/react/src/api/otlp-redaction.ts @@ -0,0 +1,90 @@ +// --------------------------------------------------------------------------- +// Export-seam redaction for the BROWSER isolate's OTLP pipeline. +// +// The web client's `OtlpTracer` stamps the same credential-bearing values the +// worker's tracer does — `HttpClient` writes `url.full` and `url.query` on +// every `http.client` span, and a failed span carries the interpolated log +// message, `effect.cause`, and `exception.*` as event text — and ships them to +// `/v1/traces`, which the worker forwards to Axiom. The policy is shared with +// the Workers isolates (`@executor-js/sdk/shared`); only the seam differs. +// +// `OtlpTracer` has no processor hook: it builds the OTLP payload itself and +// hands it to `OtlpSerialization`. That handoff is the one chokepoint every +// exported span crosses, so the redaction wraps the serializer rather than the +// tracer — a tracer wrapper would have to re-implement `Tracer.Span`, and a +// per-request `HttpClient` transform would only cover the clients someone +// remembered to wire it into. +// --------------------------------------------------------------------------- + +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { OtlpSerialization } from "effect/unstable/observability/OtlpSerialization"; +import type { TraceData } from "effect/unstable/observability/OtlpTracer"; + +import { + STRIPPED_QUERY_ATTRIBUTE, + redactSpanUrlAttribute, + scrubSpanText, +} from "@executor-js/sdk/shared"; + +/** The OTLP span, taken off `TraceData` rather than redeclared — the payload + * shape is the contract this wrapper sits on. */ +type OtlpSpan = TraceData["resourceSpans"][number]["scopeSpans"][number]["spans"][number]; + +/** Rewrite one span's payload in place. The `TraceData` handed to the + * serializer is freshly built per export batch and is not shared with the + * tracer's own span objects, so mutating it affects only what is serialized. + * + * An OTLP attribute value is a tagged union (`stringValue`, `intValue`, …); + * only the string arm can carry a URL or free text. */ +const scrubSpan = (span: OtlpSpan): void => { + const stripped = new Set(); + for (const attribute of span.attributes) { + const value = attribute.value.stringValue; + if (typeof value !== "string") continue; + const result = redactSpanUrlAttribute(attribute.key, value); + if (result === null) continue; + attribute.value.stringValue = result.value; + for (const key of result.stripped) stripped.add(key); + } + if (stripped.size > 0) { + span.attributes.push({ + key: STRIPPED_QUERY_ATTRIBUTE, + value: { stringValue: Array.from(stripped).sort().join(",") }, + }); + } + span.events.forEach((event, index) => { + for (const attribute of event.attributes) { + const value = attribute.value.stringValue; + if (typeof value !== "string") continue; + attribute.value.stringValue = scrubSpanText(value); + } + // `name` is readonly on the event, so the entry is replaced rather than + // assigned through — the attribute bags above are shared with the copy. + span.events[index] = { ...event, name: scrubSpanText(event.name) }; + }); + if (typeof span.status.message === "string") { + span.status.message = scrubSpanText(span.status.message); + } +}; + +/** Wrap an `OtlpSerialization` layer so every span it serializes is scrubbed + * first. Metrics and logs pass through untouched — neither carries a span's + * URL attributes, and the log exporter is not installed on this surface. */ +export const layerRedacted = ( + inner: Layer.Layer, +): Layer.Layer => + Layer.effect( + OtlpSerialization, + Effect.map(OtlpSerialization.asEffect(), (serialization) => ({ + ...serialization, + traces: (data) => { + for (const resourceSpan of data.resourceSpans) { + for (const scopeSpan of resourceSpan.scopeSpans) { + for (const span of scopeSpan.spans) scrubSpan(span); + } + } + return serialization.traces(data); + }, + })), + ).pipe(Layer.provide(inner)); diff --git a/packages/react/src/components/add-account-modal.test.ts b/packages/react/src/components/add-account-modal.test.ts index 1d997a603..387228e40 100644 --- a/packages/react/src/components/add-account-modal.test.ts +++ b/packages/react/src/components/add-account-modal.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { Redacted } from "effect"; import { AuthTemplateSlug, IntegrationSlug, @@ -212,29 +213,41 @@ describe("mergeCustomMethods (in-session custom method append)", () => { }); }); +// Unwrap for assertions: comparing `Redacted`s structurally would compare two +// opaque wrappers and pass even if the payload carried the wrong secret. +const pastedValues = (origin: ReturnType) => { + if (origin === null || !("values" in origin)) throw new Error("expected a pasted-values origin"); + return Object.fromEntries( + Object.entries(origin.values).map(([key, value]) => [key, Redacted.value(value)]), + ); +}; + describe("createCredentialPayloadOrigin", () => { it("creates an empty-string sentinel value for no-auth connection methods", () => { expect( - createCredentialPayloadOrigin({ - origin: "paste", - inputs: [], - values: {}, - onePasswordItemId: "", - singleInput: true, - }), - ).toEqual({ values: { token: "" } }); + pastedValues( + createCredentialPayloadOrigin({ + origin: "paste", + inputs: [], + values: {}, + onePasswordItemId: "", + singleInput: true, + }), + ), + ).toEqual({ token: "" }); }); it("keeps pasted credential values trimmed and keyed by input variable", () => { - expect( - createCredentialPayloadOrigin({ - origin: "paste", - inputs: [{ variable: "token", label: "Authorization" }], - values: { token: " secret-token " }, - onePasswordItemId: "", - singleInput: true, - }), - ).toEqual({ values: { token: "secret-token" } }); + const origin = createCredentialPayloadOrigin({ + origin: "paste", + inputs: [{ variable: "token", label: "Authorization" }], + values: { token: " secret-token " }, + onePasswordItemId: "", + singleInput: true, + }); + expect(pastedValues(origin)).toEqual({ token: "secret-token" }); + // Wrapped on the way out, so browser state never holds the bare key. + expect(JSON.stringify(origin)).not.toContain("secret-token"); }); it("creates a 1Password external origin for single-input methods", () => { diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index e49378b4d..6ccd46cc2 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import * as Exit from "effect/Exit"; +import * as Redacted from "effect/Redacted"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { ConnectionName, @@ -128,7 +129,7 @@ type CredentialOrigin = "paste" | "onepassword"; type CredentialInput = { readonly variable: string; readonly label: string }; type CredentialPayloadOrigin = - | { readonly values: Record } + | { readonly values: Record> } | { readonly from: { readonly provider: ProviderKey; @@ -136,6 +137,10 @@ type CredentialPayloadOrigin = }; }; +/** Wraps as it leaves the form: from here the pasted key is only ever a + * `Redacted`, which the payload schema unwraps at the one place it belongs — + * encoding the request body. Emptiness is decided on the bare string above, + * before wrapping. */ export function createCredentialPayloadOrigin(args: { readonly origin: CredentialOrigin; readonly inputs: readonly CredentialInput[]; @@ -143,7 +148,9 @@ export function createCredentialPayloadOrigin(args: { readonly onePasswordItemId: string; readonly singleInput: boolean; }): CredentialPayloadOrigin | null { - if (args.inputs.length === 0) return { values: { token: "" } }; + // The no-auth template binds no credential; the empty `token` is the sentinel + // the server reads as "zero inputs", not a secret. + if (args.inputs.length === 0) return { values: { token: Redacted.make("") } }; if (args.origin === "onepassword") { const id = args.onePasswordItemId.trim(); if (!args.singleInput || id.length === 0) return null; @@ -152,10 +159,12 @@ export function createCredentialPayloadOrigin(args: { }; } - const values = Object.fromEntries( - args.inputs.map((input) => [input.variable, (args.values[input.variable] ?? "").trim()]), + const values = args.inputs.map( + (input) => [input.variable, (args.values[input.variable] ?? "").trim()] as const, ); - return Object.values(values).every((value) => value.length > 0) ? { values } : null; + return values.every(([, value]) => value.length > 0) + ? { values: Object.fromEntries(values.map(([key, value]) => [key, Redacted.make(value)])) } + : null; } const numberBadge = (n: number) => ( @@ -593,8 +602,10 @@ export const oauthIdentityLabelFromHealth = (input: { // Client ID Metadata Document support is not Dynamic Client Registration: // there is no POST to a registration endpoint and no provider-side app to pick. // The OAuth client identity is this host's public metadata-document URL, stored -// locally as a public PKCE client (`clientSecret: ""`) so the existing -// `oauth.start` flow can run unchanged. +// locally as a public PKCE client so the existing `oauth.start` flow can run +// unchanged. The empty `clientSecret` below is the WIRE spelling of "no +// secret" — the HTTP payload is a plain string, and the handler normalizes it +// to the domain's null before it reaches the service. // --------------------------------------------------------------------------- type CimdExistingClient = Pick< diff --git a/packages/react/src/components/health-check-editor.tsx b/packages/react/src/components/health-check-editor.tsx index ea6076fb6..fce2a6188 100644 --- a/packages/react/src/components/health-check-editor.tsx +++ b/packages/react/src/components/health-check-editor.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { useAtomValue, useAtomSet } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import * as Exit from "effect/Exit"; +import * as Redacted from "effect/Redacted"; import { rankResponseSample, type AuthTemplateSlug, @@ -263,7 +264,14 @@ function HealthCheckLivePreviewBlock(props: { }; setRunning(true); const exit = await doValidate({ - payload: { owner: preview.owner, integration, template: slug, value: credential, spec }, + payload: { + owner: preview.owner, + integration, + template: slug, + // Wrapped after the emptiness check above. + value: Redacted.make(credential), + spec, + }, }); setRunning(false); if (Exit.isFailure(exit)) { diff --git a/packages/react/src/pages/api-keys.tsx b/packages/react/src/pages/api-keys.tsx index 37459ab34..acdf5ed1f 100644 --- a/packages/react/src/pages/api-keys.tsx +++ b/packages/react/src/pages/api-keys.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Exit } from "effect"; +import { Exit, Redacted } from "effect"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; import { toast } from "sonner"; @@ -39,7 +39,9 @@ type ApiKeySummary = { readonly lastUsedAt: string | null; }; -type CreatedKey = ApiKeySummary & { readonly value: string }; +// The one-time secret arrives `Redacted` — the create response decodes it that +// way — and stays wrapped in component state. +type CreatedKey = ApiKeySummary & { readonly value: Redacted.Redacted }; const formatDate = (value: string | null): string => { if (!value) return "Never"; @@ -100,6 +102,13 @@ export function ApiKeysPage() { toast.error("Failed to revoke API key"); }; + // The deliberate unwrap: showing the key once is the whole point of the + // create dialog, and the user cannot copy a "" rendering. Scoped to + // the dialog's lifetime — `closeCreate` drops `createdKey`, so the plaintext + // is gone as soon as the dialog closes. + // oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: the one-time display is the whole point of the create dialog; scoped to its lifetime + const createdKeyPlaintext = createdKey ? Redacted.value(createdKey.value) : null; + const closeCreate = (open: boolean) => { setCreateOpen(open); if (!open) { @@ -210,19 +219,19 @@ export function ApiKeysPage() { - {createdKey ? ( + {createdKeyPlaintext !== null ? (
trackEvent("api_key_copied", { kind: "value" })} />
@@ -231,13 +240,13 @@ export function ApiKeysPage() {
trackEvent("api_key_copied", { kind: "bearer_header" })} />
diff --git a/packages/react/src/plugins/secret-form.tsx b/packages/react/src/plugins/secret-form.tsx index a3b9a9c8b..8619bff5f 100644 --- a/packages/react/src/plugins/secret-form.tsx +++ b/packages/react/src/plugins/secret-form.tsx @@ -9,6 +9,7 @@ import { } from "react"; import { useAtomSet } from "@effect/atom-react"; import * as Exit from "effect/Exit"; +import * as Redacted from "effect/Redacted"; import { createConnection } from "../api/atoms"; import { connectionWriteKeys } from "../api/reactivity-keys"; @@ -156,7 +157,9 @@ function SecretFormProvider(props: SecretFormProviderProps) { integration, template, identityLabel: displayName || id.trim(), - value: state.value.trim(), + // Wrapped as it leaves the form, after `canSubmit` rejected an empty + // field. + value: Redacted.make(state.value.trim()), }, reactivityKeys: connectionWriteKeys, }); diff --git a/scripts/oxlint-plugin-executor.js b/scripts/oxlint-plugin-executor.js index 0a09ad789..a753793d5 100644 --- a/scripts/oxlint-plugin-executor.js +++ b/scripts/oxlint-plugin-executor.js @@ -18,6 +18,7 @@ import noPromiseReject from "./oxlint-plugin-executor/rules/no-promise-reject.js import noRawDurableObjectId from "./oxlint-plugin-executor/rules/no-raw-durable-object-id.js"; import noRawFetch from "./oxlint-plugin-executor/rules/no-raw-fetch.js"; import noRawErrorThrow from "./oxlint-plugin-executor/rules/no-raw-error-throw.js"; +import noRedactedUnwrap from "./oxlint-plugin-executor/rules/no-redacted-unwrap.js"; import noRedundantPrimitiveCast from "./oxlint-plugin-executor/rules/no-redundant-primitive-cast.js"; import noRedundantErrorFactory from "./oxlint-plugin-executor/rules/no-redundant-error-factory.js"; import noSchemaClass from "./oxlint-plugin-executor/rules/no-schema-class.js"; @@ -62,6 +63,7 @@ export default { "no-raw-durable-object-id": noRawDurableObjectId, "no-raw-fetch": noRawFetch, "no-raw-error-throw": noRawErrorThrow, + "no-redacted-unwrap": noRedactedUnwrap, "no-redundant-primitive-cast": noRedundantPrimitiveCast, "no-redundant-error-factory": noRedundantErrorFactory, "no-schema-class": noSchemaClass, diff --git a/scripts/oxlint-plugin-executor/rules/no-redacted-unwrap.js b/scripts/oxlint-plugin-executor/rules/no-redacted-unwrap.js new file mode 100644 index 000000000..1b0c851c4 --- /dev/null +++ b/scripts/oxlint-plugin-executor/rules/no-redacted-unwrap.js @@ -0,0 +1,39 @@ +import { + getPropertyName, + isDeclarationFile, + isIdentifier, + isTestLike, + unwrapExpression, +} from "../utils.js"; + +const message = + 'Unwrapping a credential is a boundary decision, not a convenience. Keep the value `Redacted` and let the wire/persistence line unwrap it; if this IS that line, mark it: `// oxlint-disable-next-line executor/no-redacted-unwrap -- boundary: `. A missed unwrap on a write path does not throw — `Redacted`\'s toJSON renders the literal "" and it is persisted instead.'; + +// Matches the reference too, not just the call: `.map(Redacted.value)` and +// `SchemaGetter.transform(Redacted.value)` unwrap exactly as much as an +// applied call does. +const isRedactedUnwrap = (node) => + isIdentifier(unwrapExpression(node.object), "Redacted") && + getPropertyName(node.property) === "value"; + +export default { + meta: { + type: "problem", + docs: { + description: "Disallow Redacted.value outside allowlisted credential boundaries.", + }, + }, + create(context) { + // Tests unwrap to assert the bytes that actually reach a backend — the + // assertion that catches a missed unwrap in the first place. + if (isTestLike(context.filename) || isDeclarationFile(context.filename)) return {}; + + return { + MemberExpression(node) { + if (isRedactedUnwrap(node)) { + context.report({ node, message }); + } + }, + }; + }, +};