Skip to content
Open
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
11 changes: 11 additions & 0 deletions .changeset/credential-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@executor-js/sdk": patch
"@executor-js/hosted-server": patch
"@executor-js/hosted-self-host": patch
---

A `Credentials` store may add `renew` and `revoke`. When it does, OAuth renewal and the
revocation that follows account deletion run inside the store, and the host never reads the
refresh token or client secret. Self-host takes `EXECUTOR_CREDENTIAL_ADAPTER_URL` to use such a
store over HTTP; every sealed record then goes through it, and `EXECUTOR_ENCRYPTION_KEY` seals
nothing.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,26 @@ Deployments retain a Git commit reference, file count, build reference and decla
account requirements. Source reads load files from Git; SQL stores no source bytes. Public app requirements come from the active deployment. No
live tool catalog is stored.

A `Credentials` store may also implement `renew` and `revoke`. OAuth renewal
and the revocation after an account is deleted then run inside the store, and
the host never reads the refresh token or client secret. Self-host uses such a
store over HTTP when `EXECUTOR_CREDENTIAL_ADAPTER_URL` is set: it posts JSON to
`/encrypt`, `/decrypt`, `/renew` and `/revoke` under that URL, with sealed bytes
in base64. The host sends no credential of its own, so the adapter must
authenticate it, for example with mTLS or a network policy. An adapter on a
private or internal host needs its exact HTTP origin in
`EXECUTOR_URL_ALLOW_HTTP_ORIGINS`, like any internal endpoint the host reaches.
The host waits 30 seconds for each answer, so `/renew` must give the service a
shorter deadline of its own.
`EXECUTOR_ENCRYPTION_KEY` is still read, or generated when unset, but seals
nothing. The store holds every sealed record, not only OAuth grants, so with the
setting on every encrypt and decrypt in OAuth, webhooks, workflows, tool
approvals, events, accounts and credential handles goes to the adapter, and an
adapter outage stops webhook and workflow secret handling too. The setting is
not a migration: nothing re-seals existing records, so turn it on before the
first account is saved, and turning it on or off afterwards leaves every record
sealed the other way unreadable.

The schema enforces unique app names per owner, account provider references,
and active deployments from the app's code lineage. Owners may differ across
related records. Saved account maps preserve missing slots, scalar IDs and
Expand Down
2 changes: 2 additions & 0 deletions apps/hosted/self-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ Optional overrides are `BETTER_AUTH_URL` (exact public origin),
`BETTER_AUTH_SECRET` (at least 32 characters), and `EXECUTOR_ENCRYPTION_KEY`
(exactly 64 hexadecimal characters). Explicit keys stay in your secret manager;
the server saves only keys it generates. Keep the original values across upgrades.
`EXECUTOR_CREDENTIAL_ADAPTER_URL` points sealing and OAuth renewal at an external
credentials store instead of that key; see the SDK section of the root README.

Open [http://localhost:4400](http://localhost:4400) and complete the first-admin setup.
Self-host uses password login by default. Add an app, then connect an account from
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import {
noOrganizationRemovals,
lazyHostedApiDocument,
clientMetadataSetting,
credentialAdapterSetting,
httpCredentialAdapter,
hostedOAuthClientName,
withDeploySetupWake,
withExecutorAnalytics,
Expand Down Expand Up @@ -70,6 +72,7 @@ export const selfHostExecutorServices = <E, R>(
const key = yield* Config.Redacted("EXECUTOR_ENCRYPTION_KEY");
const origin = yield* Config.String("BETTER_AUTH_URL");
const clientMetadata = yield* clientMetadataSetting(origin);
const credentialAdapter = yield* credentialAdapterSetting(egress);
const storage = yield* makeExecutorStorage({ provider: "postgresql" });
const evaluation = yield* declarationConfig;
const server = yield* Scope.Scope;
Expand All @@ -82,7 +85,9 @@ export const selfHostExecutorServices = <E, R>(
);
const executor = yield* createExecutor({
database: storage,
secret: key,
...(Option.isSome(credentialAdapter)
? { credentials: httpCredentialAdapter(credentialAdapter.value, egress) }
: { secret: key }),
origin,
git: repositories,
blobs,
Expand Down
7 changes: 7 additions & 0 deletions apps/hosted/server/src/contracts/credential-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Schema } from "effect";

/** `EXECUTOR_CREDENTIAL_ADAPTER_URL` is not an adapter origin the host's egress policy allows. */
export class CredentialAdapterUrlInvalid extends Schema.TaggedError<CredentialAdapterUrlInvalid>()(
"CredentialAdapterUrlInvalid",
{ message: Schema.String },
) {}
123 changes: 123 additions & 0 deletions apps/hosted/server/src/implementation/credential-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* A credentials store reached over HTTP. The adapter owns encryption, key custody, OAuth renewal
* and revocation; the host sends it only identities and sealed bytes, and never logs a body.
*/
import { parseDestination, type HostEgress } from "@executor-js/utils/url-policy";
import {
CredentialsError,
CredentialsRenewalRefused,
JsonObject,
type Credentials,
} from "@executor-js/sdk/core";
import { Config, Effect, Option, Redacted, Schema } from "effect";
import { Base64 } from "effect/encoding";
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
import { CredentialAdapterUrlInvalid } from "../contracts/credential-adapter.ts";

/**
* `EXECUTOR_CREDENTIAL_ADAPTER_URL`, checked at startup. Unset, the host encrypts with
* `EXECUTOR_ENCRYPTION_KEY`. The adapter authenticates the host itself, by mTLS or network policy.
* Every request also passes the connect-time address check, so an adapter on a private network
* needs its exact HTTP origin in `EXECUTOR_URL_ALLOW_HTTP_ORIGINS`.
*/
export const credentialAdapterSetting = (egress: HostEgress) =>
Config.String("EXECUTOR_CREDENTIAL_ADAPTER_URL").pipe(
Config.option,
Effect.flatMap(
Option.match({
onNone: () => Effect.succeed(Option.none<URL>()),
onSome: (value) => {
const url = parseDestination(value, egress.policy);
// Routes are joined to the path, so a query would be dropped from every request.
return url === undefined || url.search !== ""
? Effect.fail(
new CredentialAdapterUrlInvalid({
message:
"EXECUTOR_CREDENTIAL_ADAPTER_URL must be an HTTPS URL, or an HTTP URL this host allows (loopback, or an origin in EXECUTOR_URL_ALLOW_HTTP_ORIGINS), with no credentials, query or fragment. An adapter on a private or internal host needs its exact HTTP origin in EXECUTOR_URL_ALLOW_HTTP_ORIGINS.",
}),
)
: Effect.succeed(Option.some(url));
},
}),
),
);

const Sealed = Schema.Struct({ bytes: Schema.String });
const Opened = Schema.Struct({ fields: JsonObject });
const Renewed = Schema.Struct({ tokens: JsonObject, bytes: Schema.String });
const Refused = Schema.toCodecJson(CredentialsRenewalRefused);
const Revoked = Schema.Struct({
outcome: Schema.Literals(["revoked", "unsupported", "no_token", "failed"]),
});

const bytesOf = (value: string) =>
Effect.fromResult(Base64.decode(value)).pipe(Effect.mapError(() => new CredentialsError()));

/**
* `POST {url}/encrypt|decrypt|renew|revoke` with JSON bodies; bytes travel as base64. Any
* transport failure, unexpected status or undecodable body is a `CredentialsError`. `/renew`
* answers 422 with the refusal's fields when the service refused the renewal. The host waits 30
* seconds for each answer, so `/renew` must give the service a shorter deadline of its own.
*/
export const httpCredentialAdapter = (url: URL, egress: HostEgress): Credentials => {
const post = (route: string, body: object) =>
Effect.gen(function* () {
// The request belongs to this scope: closing it aborts a body nobody reads.
const response = yield* HttpClient.withScope(egress.client).execute(
HttpClientRequest.post(
new URL(`${url.pathname.replace(/\/$/, "")}/${route}`, url).href,
).pipe(HttpClientRequest.bodyJsonUnsafe(body)),
);
yield* Effect.annotateCurrentSpan("http.response.status_code", response.status);
return { status: response.status, json: yield* response.json };
}).pipe(
Effect.scoped,
Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }),
Effect.timeout("30 seconds"),
Effect.mapError(() => new CredentialsError()),
);
const read = <A>(schema: Schema.Decoder<A>, answer: { status: number; json: unknown }) =>
answer.status === 200
? Schema.decodeUnknownEffect(schema)(answer.json).pipe(
Effect.mapError(() => new CredentialsError()),
)
: Effect.fail(new CredentialsError());
return {
encrypt: (identity, fields) =>
post("encrypt", { identity, fields: Redacted.value(fields) }).pipe(
Effect.flatMap((answer) => read(Sealed, answer)),
Effect.flatMap(({ bytes }) => bytesOf(bytes)),
Effect.withSpan("sdk.credentials.encrypt"),
),
decrypt: (identity, bytes) =>
post("decrypt", { identity, bytes: Base64.encode(Redacted.value(bytes)) }).pipe(
Effect.flatMap((answer) => read(Opened, answer)),
Effect.map(({ fields }) => Redacted.make(fields)),
Effect.withSpan("sdk.credentials.decrypt"),
),
renew: (identity, sealed) =>
post("renew", { identity, bytes: Base64.encode(Redacted.value(sealed)) }).pipe(
Effect.flatMap((answer) =>
answer.status === 422
? Schema.decodeUnknownEffect(Refused)({
...(typeof answer.json === "object" ? answer.json : {}),
_tag: "CredentialsRenewalRefused",
}).pipe(
Effect.mapError(() => new CredentialsError()),
Effect.flatMap(Effect.fail),
)
: read(Renewed, answer),
),
Effect.flatMap(({ tokens, bytes }) =>
bytesOf(bytes).pipe(Effect.map((sealed) => ({ tokens: Redacted.make(tokens), sealed }))),
),
Effect.withSpan("sdk.credentials.renew"),
),
revoke: (identity, sealed) =>
post("revoke", { identity, bytes: Base64.encode(Redacted.value(sealed)) }).pipe(
Effect.flatMap((answer) => read(Revoked, answer)),
Effect.map(({ outcome }) => outcome),
Effect.withSpan("sdk.credentials.revoke"),
),
};
};
5 changes: 5 additions & 0 deletions apps/hosted/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ export {
clientMetadataSetting,
type ClientMetadataSetting,
} from "./implementation/oauth-client-metadata.ts";
export * from "./contracts/credential-adapter.ts";
export {
credentialAdapterSetting,
httpCredentialAdapter,
} from "./implementation/credential-adapter.ts";
export type {
HostedAccountConnection,
HostedOAuthSignIn,
Expand Down
84 changes: 82 additions & 2 deletions packages/sdk/src/contracts/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
} from "./shared.ts";
import { AccountConnectionDestination } from "./account-connection.ts";
import type { Effect, Redacted } from "effect";
import type { OAuthClientId, OAuthAttemptId } from "./oauth.ts";
import { OAuthProviderErrorCode, type OAuthClientId, type OAuthAttemptId } from "./oauth.ts";

/**
* Account metadata plus an opaque encrypted credential envelope. The future
Expand Down Expand Up @@ -96,7 +96,51 @@ export interface StorageHost {
effect: Effect.Effect<A, E, R>,
) => Effect.Effect<A, E | StorageError, R>;
}
/** The host owns encryption and key custody. Ciphertexts are bound to their stable resource identity. */
/**
* A credentials store refused to renew a grant, in the terms of the service's answer. The host
* classifies it as it classifies its own token request. `subject_changed` ends the grant on its
* own. `invalid_grant` ends it only with the service's `status`, below 500 and not 429; with a
* 2xx status it also needs `answer: "error_body"`. A refusal with no `status` reads as an
* incompatible response, as a lost answer to the host's own request would, and keeps the grant.
*/
export class CredentialsRenewalRefused extends Schema.TaggedError<CredentialsRenewalRefused>()(
"CredentialsRenewalRefused",
{
reason: Schema.Literals([
"request",
"invalid_grant",
"invalid_client",
"invalid_response",
"subject_changed",
]),
status: Schema.optional(Schema.Int),
providerError: Schema.optional(OAuthProviderErrorCode),
/** The service answered with an RFC 6749 §5.2 error body, or a WWW-Authenticate challenge. */
answer: Schema.optional(Schema.Literals(["error_body", "challenge"])),
/** With HTTP 429, the time the answer's Retry-After header named. */
retryAfter: Schema.optional(Schema.Date),
},
) {}

/** A renewal a credentials store performed. */
export interface CredentialsRenewed {
/**
* Public token response members. Never refresh_token, id_token, client_secret or
* client_assertion.
*/
readonly tokens: Redacted.Redacted<JsonObject>;
/**
* The grant sealed again, holding any rotated refresh token. The host decrypts it, sets the
* renewed fields and lifetime and encrypts it once more, so the store's `encrypt` must keep its
* own placeholders, or the rotated refresh token is lost.
*/
readonly sealed: Uint8Array;
}

/**
* The store owns encryption and key custody; a store with `renew` also owns the refresh exchange.
* Ciphertexts are bound to their stable resource identity.
*/
export interface Credentials {
readonly encrypt: (
identity:
Expand Down Expand Up @@ -124,4 +168,40 @@ export interface Credentials {
| import("./events.ts").StoredEventId,
bytes: Redacted.Redacted<Uint8Array>,
) => Effect.Effect<Redacted.Redacted<JsonObject>, CredentialsError>;
/**
* Renew an OAuth grant from the store's own sealed copy, so the host never reads its refresh
* token or client secret. When present, the host calls it instead of its own token request and
* keeps its claim, lease and outcome classification.
*
* The host seals several records under the same identities and still runs sign-in and the
* client credentials setup exchange itself. So a store may replace `refreshToken` and
* `client.client_secret` with placeholders only in the grant: a record under an `acc_` identity
* with `server`, `client`, `fields` and `response` keys. That shape is part of this contract:
* the host keeps it, and seals no other `acc_` record with all four keys. The account fields
* under the same identity, the sign-in attempt under `oauth_` and the saved client under
* `client_` (top-level `client_secret`) must decrypt to their real values. The host reads only
* whether the placeholders are present.
*
* Performs the RFC 6749 `refresh_token` or `client_credentials` request, and for a refreshed ID
* token the OIDC Core §12.2 subject check (reason `subject_changed`). Returns no secret. If the
* service rotates the token but the answer never reaches the host, the host keeps the old seal,
* as it does after a lost answer to its own token request. So a store's deadline for the
* service should be shorter than any deadline it is called under.
*/
readonly renew?: (
identity: AccountId,
sealed: Redacted.Redacted<Uint8Array>,
) => Effect.Effect<CredentialsRenewed, CredentialsError | CredentialsRenewalRefused>;
/**
* RFC 7009 revocation of a deleted account's grant with the store's real token: the refresh
* token when present, otherwise the access token. Outcomes match the host's own revocation.
* The host calls it once, after deleting the account, whatever the outcome; how long the store
* keeps the grant's secrets after that is the store's decision. The host does not check the
* revocation endpoint against its URL policy first, as its own request does; the endpoint
* passed that policy at sign-in, and applying the current policy is the store's choice.
*/
readonly revoke?: (
identity: AccountId,
sealed: Redacted.Redacted<Uint8Array>,
) => Effect.Effect<"revoked" | "unsupported" | "no_token" | "failed", CredentialsError>;
}
29 changes: 28 additions & 1 deletion packages/sdk/src/implementation/oauth-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import {
} from "../contracts/oauth.ts";
import type { OAuthTokenRequestFormat, OAuthTokenResponse } from "apps/contracts";
import type { ProviderAuthMethod } from "../contracts/provider.ts";
import type { CredentialsError } from "../contracts/shared.ts";
import { CredentialsRenewalRefused } from "../contracts/storage.ts";
import {
challengeDiagnostics,
descriptionLength,
Expand Down Expand Up @@ -472,6 +474,31 @@ const callbackFailure = (error: unknown): OAuthProtocolFailed => {
export const isOAuthErrorResponse = (error: OAuthProtocolFailed) =>
error.code === oauth.RESPONSE_BODY_ERROR || error.code === oauth.WWW_AUTHENTICATE_CHALLENGE;

/**
* A renewal a credentials store refused, as the same answer to the host's own request would have
* failed. A store that cannot be reached or answers outside its contract is an outage: the grant
* is kept and renewed again on its next use.
*/
export const fromRefusal = (
refusal: CredentialsRenewalRefused | CredentialsError,
): OAuthProtocolFailed => {
if (!Schema.is(CredentialsRenewalRefused)(refusal))
return new OAuthProtocolFailed({ reason: "request" });
const code =
refusal.answer === "error_body"
? oauth.RESPONSE_BODY_ERROR
: refusal.answer === "challenge"
? oauth.WWW_AUTHENTICATE_CHALLENGE
: undefined;
return new OAuthProtocolFailed({
reason: refusal.reason,
...(code === undefined ? {} : { code }),
...(refusal.status === undefined ? {} : { status: refusal.status }),
...(refusal.providerError === undefined ? {} : { providerError: refusal.providerError }),
...(refusal.retryAfter === undefined ? {} : { retryAfter: refusal.retryAfter }),
});
};

const observeFailure = (error: OAuthProtocolFailed) =>
Effect.annotateCurrentSpan({
"oauth.error.reason": error.reason,
Expand Down Expand Up @@ -698,7 +725,7 @@ const optionalTokenMembers = new Set([
* space-delimited string. A missing `token_type` is Bearer: Shopify, ClickUp and Mailchimp omit
* it, and Executor sends every access token as a Bearer token.
*/
const normalizedTokens = (body: object) => {
export const normalizedTokens = (body: object) => {
const tokens: Record<string, unknown> = {};
for (const [key, value] of Object.entries(body)) {
if (optionalTokenMembers.has(key) && (value === null || (value === "" && key !== "scope")))
Expand Down
Loading