Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/brave-donkeys-invite.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions .changeset/hungry-parrots-repeat.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions .changeset/lucky-mirrors-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"executor": patch
---

A credential pasted into the connect flow is now `Redacted<string>` 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<string>` 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.
10 changes: 10 additions & 0 deletions .changeset/olive-herons-wander.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"executor": patch
---

`CredentialProvider.get` now returns `Redacted<string> | null`, so a resolved
credential cannot reach a log, span attribute, or error message without an
explicit unwrap. `set` accepts `string | Redacted<string>`, 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.
29 changes: 29 additions & 0 deletions .changeset/olive-pandas-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"executor": minor
---

Resolved credentials are `Redacted<string>` 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<string> | null`, and `ToolInvocationCredential`'s `value` / `values`
entries are `Redacted<string> | null`. Inputs are widened rather than narrowed —
`set`, `setDefault`, and the OAuth secret inputs all accept
`string | Redacted<string>` — 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
`"<redacted>"` — 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.
13 changes: 13 additions & 0 deletions .changeset/plenty-jars-repeat.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 23 additions & 0 deletions .changeset/quiet-moons-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"executor": patch
---

OAuth token material is now carried as `Redacted<string>`. `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<string>`, 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.
23 changes: 23 additions & 0 deletions .changeset/tidy-falcons-gather.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"executor": patch
---

Resolved connection credentials are now carried as `Redacted<string>` 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<string> | null`; `ctx.connections.
resolveValue`, `resolveTools`'s `getValue` / `getValues`, and `ctx.providers.get`
return `Redacted<string> | null`. `ctx.providers.setDefault` accepts
`string | Redacted<string>`, 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 `"<redacted>"`.

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.
1 change: 1 addition & 0 deletions .oxlintrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion apps/cloud/executor.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
58 changes: 52 additions & 6 deletions apps/cloud/src/api/protected-api-key-auth.node.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<string>) =>
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,
Expand Down Expand Up @@ -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 "<redacted>" 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",
Expand Down Expand Up @@ -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([]);
}),
);
});
55 changes: 51 additions & 4 deletions apps/cloud/src/auth/api-keys.node.test.ts
Original file line number Diff line number Diff line change
@@ -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 "<redacted>"
// rendering cannot pass for the real key.
const CREATED_SECRET = "synthetic-created-key";

const stubWorkOS = (overrides: Partial<WorkOSClientService>) =>
Layer.succeed(
WorkOSClient,
Expand All @@ -18,7 +22,7 @@ const stubWorkOS = (overrides: Partial<WorkOSClientService>) =>
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(
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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("<redacted>");
}),
);

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 "<redacted>".
expect(seen).toEqual(["synthetic-inbound-key"]);
expect(owner?.accountId).toBe("user_123");
}),
);
});
18 changes: 13 additions & 5 deletions apps/cloud/src/auth/api-keys.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<string>;
};

export class ApiKeyValidationError extends Data.TaggedError("ApiKeyValidationError")<{
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -134,7 +140,9 @@ const createdFromResponse = (value: unknown): CreatedApiKey | null =>
export class ApiKeyService extends Context.Service<
ApiKeyService,
{
readonly validate: (value: string) => Effect.Effect<ApiKeyOwner | null, ApiKeyValidationError>;
readonly validate: (
value: Redacted.Redacted<string>,
) => Effect.Effect<ApiKeyOwner | null, ApiKeyValidationError>;
readonly listUserKeys: (input: {
readonly accountId: string;
readonly organizationId: string;
Expand All @@ -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 })),
Expand Down
Loading
Loading