From 8168289de41a123b273e3ceb1eb58b0257cd0fe3 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:01:31 +0200 Subject: [PATCH 1/5] Let a credentials store run OAuth renewal and revocation itself --- .changeset/credential-adapter.md | 10 ++ README.md | 9 ++ .../src/implementation/executor-services.ts | 7 +- .../src/implementation/credential-adapter.ts | 124 ++++++++++++++++++ apps/hosted/server/src/index.ts | 5 + packages/sdk/src/contracts/storage.ts | 70 +++++++++- .../sdk/src/implementation/oauth-protocol.ts | 27 ++++ packages/sdk/src/implementation/oauth.ts | 94 ++++++++++--- 8 files changed, 326 insertions(+), 20 deletions(-) create mode 100644 .changeset/credential-adapter.md create mode 100644 apps/hosted/server/src/implementation/credential-adapter.ts diff --git a/.changeset/credential-adapter.md b/.changeset/credential-adapter.md new file mode 100644 index 0000000000..6f742f20c2 --- /dev/null +++ b/.changeset/credential-adapter.md @@ -0,0 +1,10 @@ +--- +"@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 instead of `EXECUTOR_ENCRYPTION_KEY`; every sealed record then goes through it. diff --git a/README.md b/README.md index 9288bbb00a..fe59beb9b6 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,15 @@ Accounts store metadata and opaque encrypted credential bytes. The credential adapter owns encryption, envelope format and key custody. The local server uses AES-GCM with an explicitly configured key. Parsed storage records redact the bytes; public account records contain only metadata. + +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, instead of `EXECUTOR_ENCRYPTION_KEY`: it posts JSON to +`/encrypt`, `/decrypt`, `/renew` and `/revoke` under that URL, with sealed bytes in base64. 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. Deployments retain a Git commit reference, file count, build reference and declared 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. diff --git a/apps/hosted/self-host/src/implementation/executor-services.ts b/apps/hosted/self-host/src/implementation/executor-services.ts index fd698ba89c..6847b849a8 100644 --- a/apps/hosted/self-host/src/implementation/executor-services.ts +++ b/apps/hosted/self-host/src/implementation/executor-services.ts @@ -30,6 +30,8 @@ import { noOrganizationRemovals, lazyHostedApiDocument, clientMetadataSetting, + credentialAdapterSetting, + httpCredentialAdapter, hostedOAuthClientName, withDeploySetupWake, withExecutorAnalytics, @@ -70,6 +72,7 @@ export const selfHostExecutorServices = ( 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; @@ -82,7 +85,9 @@ export const selfHostExecutorServices = ( ); const executor = yield* createExecutor({ database: storage, - secret: key, + ...(Option.isSome(credentialAdapter) + ? { credentials: httpCredentialAdapter(credentialAdapter.value, egress) } + : { secret: key }), origin, git: repositories, blobs, diff --git a/apps/hosted/server/src/implementation/credential-adapter.ts b/apps/hosted/server/src/implementation/credential-adapter.ts new file mode 100644 index 0000000000..8e2e4485ff --- /dev/null +++ b/apps/hosted/server/src/implementation/credential-adapter.ts @@ -0,0 +1,124 @@ +/** + * 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"; + +/** `EXECUTOR_CREDENTIAL_ADAPTER_URL` is not an adapter origin the host's egress policy allows. */ +export class CredentialAdapterUrlInvalid extends Schema.TaggedError()( + "CredentialAdapterUrlInvalid", + { message: Schema.String }, +) {} + +/** + * `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. + */ +export const credentialAdapterSetting = (egress: HostEgress) => + Config.String("EXECUTOR_CREDENTIAL_ADAPTER_URL").pipe( + Config.option, + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(Option.none()), + onSome: (value) => { + const url = parseDestination(value, egress.policy); + return url === undefined + ? Effect.fail( + new CredentialAdapterUrlInvalid({ + message: + "EXECUTOR_CREDENTIAL_ADAPTER_URL must be an HTTPS URL, or loopback HTTP where this host allows it, with no credentials or fragment.", + }), + ) + : 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. + */ +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 = (schema: Schema.Decoder, 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"), + ), + }; +}; diff --git a/apps/hosted/server/src/index.ts b/apps/hosted/server/src/index.ts index fb360e6138..d36eec3e6c 100644 --- a/apps/hosted/server/src/index.ts +++ b/apps/hosted/server/src/index.ts @@ -50,6 +50,11 @@ export { clientMetadataSetting, type ClientMetadataSetting, } from "./implementation/oauth-client-metadata.ts"; +export { + CredentialAdapterUrlInvalid, + credentialAdapterSetting, + httpCredentialAdapter, +} from "./implementation/credential-adapter.ts"; export type { HostedAccountConnection, HostedOAuthSignIn, diff --git a/packages/sdk/src/contracts/storage.ts b/packages/sdk/src/contracts/storage.ts index adc0e9e9ac..6ac7f17426 100644 --- a/packages/sdk/src/contracts/storage.ts +++ b/packages/sdk/src/contracts/storage.ts @@ -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 @@ -96,7 +96,46 @@ export interface StorageHost { effect: Effect.Effect, ) => Effect.Effect; } -/** 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: only `invalid_grant` and + * `subject_changed` end the grant. + */ +export class CredentialsRenewalRefused extends Schema.TaggedError()( + "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; + /** + * 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 `encrypt` keeps the store's own + * placeholders. + */ + 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: @@ -124,4 +163,31 @@ export interface Credentials { | import("./events.ts").StoredEventId, bytes: Redacted.Redacted, ) => Effect.Effect, 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` and `fields` 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. + */ + readonly renew?: ( + identity: AccountId, + sealed: Redacted.Redacted, + ) => Effect.Effect; + /** + * 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. + */ + readonly revoke?: ( + identity: AccountId, + sealed: Redacted.Redacted, + ) => Effect.Effect<"revoked" | "unsupported" | "no_token" | "failed", CredentialsError>; } diff --git a/packages/sdk/src/implementation/oauth-protocol.ts b/packages/sdk/src/implementation/oauth-protocol.ts index d722917d88..4fd6926189 100644 --- a/packages/sdk/src/implementation/oauth-protocol.ts +++ b/packages/sdk/src/implementation/oauth-protocol.ts @@ -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, @@ -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, diff --git a/packages/sdk/src/implementation/oauth.ts b/packages/sdk/src/implementation/oauth.ts index 3042819f55..eb1f91f621 100644 --- a/packages/sdk/src/implementation/oauth.ts +++ b/packages/sdk/src/implementation/oauth.ts @@ -71,10 +71,11 @@ import { query, transaction, type Query } from "./database.ts"; import { storedProfile } from "./profiles.ts"; import { clientRegistration, + fromRefusal, idTokenIdentity, isOAuthErrorResponse, makeOAuthProtocol, - type OAuthProtocolFailed, + OAuthProtocolFailed, } from "./oauth-protocol.ts"; import { defaultLabel, ownedAccount } from "./accounts.ts"; import type { OAuthCallbackField, OAuthFailureDetail } from "./oauth-diagnostics.ts"; @@ -82,15 +83,15 @@ import type { OAuthCallbackField, OAuthFailureDetail } from "./oauth-diagnostics const decode = (schema: Schema.Decoder, value: unknown) => Schema.decodeUnknownEffect(schema)(value).pipe(Effect.mapError(() => new StorageError())); +/** Token response members that stay with the host or the credentials store, never the account. */ +const hostOnly = ["refresh_token", "id_token", "client_secret", "client_assertion"]; + /** Apply the authored output schema after removing host-only token material. */ const project = (response: JsonObject, fields: unknown) => Effect.gen(function* () { const input = yield* decode(JsonObject, fields); const publicFields = Object.fromEntries( - Object.entries(input).filter( - ([key]) => - !["refresh_token", "id_token", "client_secret", "client_assertion"].includes(key), - ), + Object.entries(input).filter(([key]) => !hostOnly.includes(key)), ); const decoder = yield* Effect.try({ try: () => @@ -126,6 +127,25 @@ const retryAfterOf = (reason: string, error: OAuthProtocolFailed) => ? { retryAfter: error.retryAfter } : {}; +/** + * The tokens a credentials store's renewal returned, without host-only members. RFC 6749 §5.1 + * gives `expires_in` as a number; any other shape is a response Executor cannot use. + */ +const RenewedTokens = Schema.StructWithRest( + Schema.Struct({ expires_in: Schema.optional(Schema.Number) }), + [JsonObject], +); +const renewedTokens = (tokens: Redacted.Redacted) => + Schema.decodeUnknownEffect(RenewedTokens)( + Object.fromEntries( + Object.entries(Redacted.value(tokens)).filter(([key]) => !hostOnly.includes(key)), + ), + ).pipe( + Effect.mapError( + () => new OAuthProtocolFailed({ reason: "invalid_response", code: "schema_decode" }), + ), + ); + /** * RFC 6749 §5.1 gives a token's lifetime in seconds from issue. oauth4webapi rejects negative * values. A lifetime of zero states no usable lifetime, so it is treated like an omitted one @@ -1189,12 +1209,40 @@ export const makeOAuth = ( return Redacted.make(grant.fields); if (protocol === undefined) return yield* reconnect("not_renewable"); const stage = grant.grant === "client_credentials" ? "clientCredentials" : "refresh"; - const renewal = - grant.grant === "client_credentials" - ? protocol.clientCredentials(grant) - : grant.refreshToken === undefined - ? undefined - : protocol.refresh({ ...grant, refreshToken: grant.refreshToken }); + const store = credentials.renew; + if (store !== undefined) + yield* Effect.annotateCurrentSpan("oauth.renewal.custody", "store"); + // A store that renews holds the real refresh token and client secret; the host applies + // its URL policy to the saved token endpoint, as its own transport would. + const renewal: + | Effect.Effect< + { + readonly tokens: { + readonly expires_in?: number | undefined; + readonly [key: string]: unknown; + }; + readonly sealed?: Uint8Array | undefined; + }, + OAuthProtocolFailed + > + | undefined = + store !== undefined + ? options === undefined || + parseDestination(grant.server.token_endpoint, options.urlPolicy) === undefined + ? Effect.fail(new OAuthProtocolFailed({ reason: "destination_blocked" })) + : store(account.id, Redacted.make(row.encrypted)).pipe( + Effect.mapError(fromRefusal), + Effect.flatMap(({ tokens, sealed }) => + renewedTokens(tokens).pipe(Effect.map((tokens) => ({ tokens, sealed }))), + ), + ) + : grant.grant === "client_credentials" + ? protocol.clientCredentials(grant).pipe(Effect.map((tokens) => ({ tokens }))) + : grant.refreshToken === undefined + ? undefined + : protocol + .refresh({ ...grant, refreshToken: grant.refreshToken }) + .pipe(Effect.map((tokens) => ({ tokens }))); if (renewal === undefined) return yield* reconnect("not_renewable"); const claim = `refresh_${yield* nextId}`; /** Renew under the claim and save the outcome; undefined when the claim was lost. */ @@ -1210,7 +1258,7 @@ export const makeOAuth = ( retry: retryAfterOf(outcome, error), }; }), - Effect.flatMap((tokens) => + Effect.flatMap(({ tokens, sealed }) => project(grant.response, { ...grant.fields, ...tokens }).pipe( // The service issued tokens, but not in the shape the provider declares. Effect.mapError(() => ({ @@ -1219,7 +1267,7 @@ export const makeOAuth = ( identityChanged: false, retry: {}, })), - Effect.map((fields) => ({ tokens, fields })), + Effect.map((fields) => ({ tokens, fields, sealed })), ), ), Effect.result, @@ -1287,15 +1335,24 @@ export const makeOAuth = ( ...retry, }); } - const { fields, tokens } = result.success; + const { fields, tokens, sealed } = result.success; const updatedAt = new Date(yield* Clock.currentTimeMillis); + // A store that renewed sealed the grant again with any rotated refresh token. Its view of + // that grant takes the renewed fields and lifetime, and the store seals it once more. + const renewed = + sealed === undefined ? grant : yield* decrypt(account.id, sealed, OAuthGrant); // The renewed token's lifetime replaces the previous one, including when it states none. const updated = yield* decode(OAuthGrant, { - ...Struct.omit(grant, ["expiresAt"]), + ...Struct.omit(renewed, ["expiresAt"]), fields, - ...(grant.grant === "client_credentials" + ...(grant.grant === "client_credentials" || sealed !== undefined ? {} - : { refreshToken: tokens.refresh_token ?? grant.refreshToken }), + : { + refreshToken: + typeof tokens.refresh_token === "string" + ? tokens.refresh_token + : grant.refreshToken, + }), ...expiry(updatedAt.getTime(), tokens.expires_in), }); const encrypted = yield* encrypt(account.id, updated); @@ -1508,6 +1565,9 @@ export const makeOAuth = ( Effect.gen(function* () { yield* Effect.annotateCurrentSpan("oauth.provider.id", removed.provider); if (protocol === undefined) return "unsupported" as const; + // A store that holds the grant's tokens revokes with them itself. + if (credentials.revoke !== undefined) + return yield* credentials.revoke(removed.account, Redacted.make(removed.encrypted)); const grant = yield* decrypt(removed.account, removed.encrypted, OAuthGrant); if (grant.server.revocation_endpoint === undefined) return "unsupported" as const; const accessToken = grant.fields["access_token"]; From f6bd97b599d40ea236d967d58822db866c72827b Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:30:51 +0200 Subject: [PATCH 2/5] Reconnect grants a store cannot renew and keep the store's seal through a failed save A grant with no refresh token now reconnects before the host asks the store, as it does on the AES path and as usable reports. The store's renewed seal is written under the claim before the host re-seals it, so an adapter failure in that window leaves a claim that resumes from the rotated token instead of the replaced one. The adapter URL may not carry a query, the URL error moves to contracts, and the docs say the encryption key is still read. --- .changeset/credential-adapter.md | 3 +- README.md | 6 +- apps/hosted/self-host/README.md | 2 + .../src/contracts/credential-adapter.ts | 7 ++ .../src/implementation/credential-adapter.ts | 12 ++-- apps/hosted/server/src/index.ts | 2 +- packages/sdk/src/contracts/storage.ts | 9 ++- packages/sdk/src/implementation/oauth.ts | 67 +++++++++++-------- 8 files changed, 64 insertions(+), 44 deletions(-) create mode 100644 apps/hosted/server/src/contracts/credential-adapter.ts diff --git a/.changeset/credential-adapter.md b/.changeset/credential-adapter.md index 6f742f20c2..787e85b307 100644 --- a/.changeset/credential-adapter.md +++ b/.changeset/credential-adapter.md @@ -7,4 +7,5 @@ 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 instead of `EXECUTOR_ENCRYPTION_KEY`; every sealed record then goes through it. +store over HTTP; every sealed record then goes through it, and `EXECUTOR_ENCRYPTION_KEY` seals +nothing. diff --git a/README.md b/README.md index fe59beb9b6..bcadbda93d 100644 --- a/README.md +++ b/README.md @@ -159,8 +159,10 @@ records redact the bytes; public account records contain only metadata. 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, instead of `EXECUTOR_ENCRYPTION_KEY`: it posts JSON to -`/encrypt`, `/decrypt`, `/renew` and `/revoke` under that URL, with sealed bytes in base64. The +`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. +`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. diff --git a/apps/hosted/self-host/README.md b/apps/hosted/self-host/README.md index 324f504052..2433d1266e 100644 --- a/apps/hosted/self-host/README.md +++ b/apps/hosted/self-host/README.md @@ -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 diff --git a/apps/hosted/server/src/contracts/credential-adapter.ts b/apps/hosted/server/src/contracts/credential-adapter.ts new file mode 100644 index 0000000000..4692fb1841 --- /dev/null +++ b/apps/hosted/server/src/contracts/credential-adapter.ts @@ -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", + { message: Schema.String }, +) {} diff --git a/apps/hosted/server/src/implementation/credential-adapter.ts b/apps/hosted/server/src/implementation/credential-adapter.ts index 8e2e4485ff..d1c4da1588 100644 --- a/apps/hosted/server/src/implementation/credential-adapter.ts +++ b/apps/hosted/server/src/implementation/credential-adapter.ts @@ -12,12 +12,7 @@ import { import { Config, Effect, Option, Redacted, Schema } from "effect"; import { Base64 } from "effect/encoding"; import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; - -/** `EXECUTOR_CREDENTIAL_ADAPTER_URL` is not an adapter origin the host's egress policy allows. */ -export class CredentialAdapterUrlInvalid extends Schema.TaggedError()( - "CredentialAdapterUrlInvalid", - { message: Schema.String }, -) {} +import { CredentialAdapterUrlInvalid } from "../contracts/credential-adapter.ts"; /** * `EXECUTOR_CREDENTIAL_ADAPTER_URL`, checked at startup. Unset, the host encrypts with @@ -31,11 +26,12 @@ export const credentialAdapterSetting = (egress: HostEgress) => onNone: () => Effect.succeed(Option.none()), onSome: (value) => { const url = parseDestination(value, egress.policy); - return url === undefined + // 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 loopback HTTP where this host allows it, with no credentials or fragment.", + "EXECUTOR_CREDENTIAL_ADAPTER_URL must be an HTTPS URL, or loopback HTTP where this host allows it, with no credentials, query or fragment.", }), ) : Effect.succeed(Option.some(url)); diff --git a/apps/hosted/server/src/index.ts b/apps/hosted/server/src/index.ts index d36eec3e6c..3b45bf083d 100644 --- a/apps/hosted/server/src/index.ts +++ b/apps/hosted/server/src/index.ts @@ -50,8 +50,8 @@ export { clientMetadataSetting, type ClientMetadataSetting, } from "./implementation/oauth-client-metadata.ts"; +export * from "./contracts/credential-adapter.ts"; export { - CredentialAdapterUrlInvalid, credentialAdapterSetting, httpCredentialAdapter, } from "./implementation/credential-adapter.ts"; diff --git a/packages/sdk/src/contracts/storage.ts b/packages/sdk/src/contracts/storage.ts index 6ac7f17426..35e1ec24bd 100644 --- a/packages/sdk/src/contracts/storage.ts +++ b/packages/sdk/src/contracts/storage.ts @@ -98,8 +98,9 @@ export interface StorageHost { } /** * 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: only `invalid_grant` and - * `subject_changed` end the grant. + * classifies it as it classifies its own token request. `subject_changed` ends the grant on its + * own; `invalid_grant` ends it only with `answer: "error_body"` or the service's `status`, since a + * refusal that names no answer from the service reads as an incompatible response. */ export class CredentialsRenewalRefused extends Schema.TaggedError()( "CredentialsRenewalRefused", @@ -171,7 +172,7 @@ export interface Credentials { * 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` and `fields` keys. The account fields under the same identity, the sign-in + * with `server`, `client`, `fields` and `response` 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. * @@ -185,6 +186,8 @@ export interface Credentials { /** * 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. */ readonly revoke?: ( identity: AccountId, diff --git a/packages/sdk/src/implementation/oauth.ts b/packages/sdk/src/implementation/oauth.ts index eb1f91f621..973459a9c6 100644 --- a/packages/sdk/src/implementation/oauth.ts +++ b/packages/sdk/src/implementation/oauth.ts @@ -83,15 +83,15 @@ import type { OAuthCallbackField, OAuthFailureDetail } from "./oauth-diagnostics const decode = (schema: Schema.Decoder, value: unknown) => Schema.decodeUnknownEffect(schema)(value).pipe(Effect.mapError(() => new StorageError())); -/** Token response members that stay with the host or the credentials store, never the account. */ -const hostOnly = ["refresh_token", "id_token", "client_secret", "client_assertion"]; - /** Apply the authored output schema after removing host-only token material. */ const project = (response: JsonObject, fields: unknown) => Effect.gen(function* () { const input = yield* decode(JsonObject, fields); const publicFields = Object.fromEntries( - Object.entries(input).filter(([key]) => !hostOnly.includes(key)), + Object.entries(input).filter( + ([key]) => + !["refresh_token", "id_token", "client_secret", "client_assertion"].includes(key), + ), ); const decoder = yield* Effect.try({ try: () => @@ -128,24 +128,30 @@ const retryAfterOf = (reason: string, error: OAuthProtocolFailed) => : {}; /** - * The tokens a credentials store's renewal returned, without host-only members. RFC 6749 §5.1 - * gives `expires_in` as a number; any other shape is a response Executor cannot use. + * The tokens a credentials store's renewal returned. RFC 6749 §5.1 gives `expires_in` as a + * number; any other shape is a response Executor cannot use. `project` drops host-only members + * before anything reaches the account. */ const RenewedTokens = Schema.StructWithRest( Schema.Struct({ expires_in: Schema.optional(Schema.Number) }), [JsonObject], ); const renewedTokens = (tokens: Redacted.Redacted) => - Schema.decodeUnknownEffect(RenewedTokens)( - Object.fromEntries( - Object.entries(Redacted.value(tokens)).filter(([key]) => !hostOnly.includes(key)), - ), - ).pipe( + Schema.decodeUnknownEffect(RenewedTokens)(Redacted.value(tokens)).pipe( Effect.mapError( () => new OAuthProtocolFailed({ reason: "invalid_response", code: "schema_decode" }), ), ); +/** A renewal's token response, with the grant a credentials store sealed again when it renewed. */ +type Renewal = Effect.Effect< + { + readonly tokens: { readonly expires_in?: number | undefined; readonly [key: string]: unknown }; + readonly sealed?: Uint8Array | undefined; + }, + OAuthProtocolFailed +>; + /** * RFC 6749 §5.1 gives a token's lifetime in seconds from issue. oauth4webapi rejects negative * values. A lifetime of zero states no usable lifetime, so it is treated like an omitted one @@ -1207,28 +1213,17 @@ export const makeOAuth = ( (!renewable && grant.expiresAt > now)) ) return Redacted.make(grant.fields); - if (protocol === undefined) return yield* reconnect("not_renewable"); + // Neither the host nor a credentials store can renew a grant without a refresh token; it + // reconnects, as `usable` reports. + if (protocol === undefined || options === undefined || !renewable) + return yield* reconnect("not_renewable"); const stage = grant.grant === "client_credentials" ? "clientCredentials" : "refresh"; const store = credentials.renew; - if (store !== undefined) - yield* Effect.annotateCurrentSpan("oauth.renewal.custody", "store"); // A store that renews holds the real refresh token and client secret; the host applies // its URL policy to the saved token endpoint, as its own transport would. - const renewal: - | Effect.Effect< - { - readonly tokens: { - readonly expires_in?: number | undefined; - readonly [key: string]: unknown; - }; - readonly sealed?: Uint8Array | undefined; - }, - OAuthProtocolFailed - > - | undefined = + const renewal: Renewal | undefined = store !== undefined - ? options === undefined || - parseDestination(grant.server.token_endpoint, options.urlPolicy) === undefined + ? parseDestination(grant.server.token_endpoint, options.urlPolicy) === undefined ? Effect.fail(new OAuthProtocolFailed({ reason: "destination_blocked" })) : store(account.id, Redacted.make(row.encrypted)).pipe( Effect.mapError(fromRefusal), @@ -1247,6 +1242,8 @@ export const makeOAuth = ( const claim = `refresh_${yield* nextId}`; /** Renew under the claim and save the outcome; undefined when the claim was lost. */ const settle = Effect.gen(function* () { + if (store !== undefined) + yield* Effect.annotateCurrentSpan("oauth.renewal.custody", "store"); const result = yield* renewal.pipe( Effect.annotateSpans("oauth.provider.id", account.provider), Effect.mapError((error) => { @@ -1336,6 +1333,16 @@ export const makeOAuth = ( }); } const { fields, tokens, sealed } = result.success; + // The service may already have rotated the refresh token the store now holds under its + // new seal. Keep that seal under the claim before anything else can fail, so a renewal + // that resumes this abandoned claim starts from it instead of the replaced token. + if (sealed !== undefined) + yield* query(() => + db.updateMany("oauthGrants", { + where: (b) => b.and(b("id", "=", account.id), b("status", "=", claim)), + set: { encrypted: sealed }, + }), + ); const updatedAt = new Date(yield* Clock.currentTimeMillis); // A store that renewed sealed the grant again with any rotated refresh token. Its view of // that grant takes the renewed fields and lifetime, and the store seals it once more. @@ -1389,7 +1396,9 @@ export const makeOAuth = ( // From claiming the grant to settling it, the renewal cannot be interrupted: a caller that // times out or disconnects would otherwise abandon a live claim, and with it any rotated // refresh token the service has already issued. The token request is bounded by its own - // timeout, so an interruption waits at most that long plus the save. + // timeout, so an interruption waits at most that long plus the save. With a credentials + // store, the renewal and the decrypt and two encrypts after it are each one bounded call + // to the store. resolution.contested = true; const renewed = yield* Effect.uninterruptible( Effect.gen(function* () { From 78cf4611c7d81d4c3ef58fa12c7f66eaf774d4b5 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:57:52 +0200 Subject: [PATCH 3/5] Keep the README paragraph intact and say the adapter setting is not a migration The adapter paragraph had split the accounts and deployments paragraph in two. It now follows that paragraph, wrapped like its neighbours, and says that turning the setting on or off after accounts exist leaves their records unreadable. The renew docs now say the store's encrypt must keep its own placeholders, and the comments state that a seal the host cannot open needs a new sign-in and that an interrupted store renewal can wait for four store calls. --- README.md | 27 ++++++++++++++---------- packages/sdk/src/contracts/storage.ts | 20 ++++++++++++------ packages/sdk/src/implementation/oauth.ts | 7 +++--- 3 files changed, 33 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index bcadbda93d..c7ccdf6f22 100644 --- a/README.md +++ b/README.md @@ -155,21 +155,26 @@ Accounts store metadata and opaque encrypted credential bytes. The credential adapter owns encryption, envelope format and key custody. The local server uses AES-GCM with an explicitly configured key. Parsed storage records redact the bytes; public account records contain only metadata. - -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. -`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. Deployments retain a Git commit reference, file count, build reference and declared 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. +`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 diff --git a/packages/sdk/src/contracts/storage.ts b/packages/sdk/src/contracts/storage.ts index 35e1ec24bd..cd76865b46 100644 --- a/packages/sdk/src/contracts/storage.ts +++ b/packages/sdk/src/contracts/storage.ts @@ -123,12 +123,15 @@ export class CredentialsRenewalRefused extends Schema.TaggedError; /** * 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 `encrypt` keeps the store's own - * placeholders. + * 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; } @@ -172,12 +175,15 @@ export interface Credentials { * 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. 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. + * with `server`, `client`, `fields` and `response` 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. + * 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. */ readonly renew?: ( identity: AccountId, diff --git a/packages/sdk/src/implementation/oauth.ts b/packages/sdk/src/implementation/oauth.ts index 973459a9c6..4a2fd2e713 100644 --- a/packages/sdk/src/implementation/oauth.ts +++ b/packages/sdk/src/implementation/oauth.ts @@ -1335,7 +1335,8 @@ export const makeOAuth = ( const { fields, tokens, sealed } = result.success; // The service may already have rotated the refresh token the store now holds under its // new seal. Keep that seal under the claim before anything else can fail, so a renewal - // that resumes this abandoned claim starts from it instead of the replaced token. + // that resumes this abandoned claim starts from it instead of the replaced token. A seal + // the store returns but cannot open again leaves the grant unusable until a new sign-in. if (sealed !== undefined) yield* query(() => db.updateMany("oauthGrants", { @@ -1397,8 +1398,8 @@ export const makeOAuth = ( // times out or disconnects would otherwise abandon a live claim, and with it any rotated // refresh token the service has already issued. The token request is bounded by its own // timeout, so an interruption waits at most that long plus the save. With a credentials - // store, the renewal and the decrypt and two encrypts after it are each one bounded call - // to the store. + // store, the renewal and the decrypt and two encrypts after it are four calls to the store, + // so an interruption can wait for all four of their timeouts plus the save. resolution.contested = true; const renewed = yield* Effect.uninterruptible( Effect.gen(function* () { From cce3a443b8c9ab514e8f0c96afde076d5b0a001a Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:20:25 +0200 Subject: [PATCH 4/5] Accept a numeric string expires_in from a store renewal and state the refusal rule exactly The host's own refresh accepts "expires_in":"3600" through oauth4webapi, so the store path now does too; before, the renewal failed as incompatible after the service had already rotated the refresh token. The CredentialsRenewalRefused doc now says what the classifier does: invalid_grant ends the grant only with the service's status. --- README.md | 6 +++++- .../src/implementation/credential-adapter.ts | 7 +++++-- packages/sdk/src/contracts/storage.ts | 21 ++++++++++++------- packages/sdk/src/implementation/oauth.ts | 9 +++++--- 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index c7ccdf6f22..80f9ee549a 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,11 @@ 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. +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 diff --git a/apps/hosted/server/src/implementation/credential-adapter.ts b/apps/hosted/server/src/implementation/credential-adapter.ts index d1c4da1588..6f658164ab 100644 --- a/apps/hosted/server/src/implementation/credential-adapter.ts +++ b/apps/hosted/server/src/implementation/credential-adapter.ts @@ -17,6 +17,8 @@ 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( @@ -31,7 +33,7 @@ export const credentialAdapterSetting = (egress: HostEgress) => ? Effect.fail( new CredentialAdapterUrlInvalid({ message: - "EXECUTOR_CREDENTIAL_ADAPTER_URL must be an HTTPS URL, or loopback HTTP where this host allows it, with no credentials, query or fragment.", + "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)); @@ -54,7 +56,8 @@ const bytesOf = (value: string) => /** * `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. + * 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) => diff --git a/packages/sdk/src/contracts/storage.ts b/packages/sdk/src/contracts/storage.ts index cd76865b46..8e7a187e80 100644 --- a/packages/sdk/src/contracts/storage.ts +++ b/packages/sdk/src/contracts/storage.ts @@ -99,8 +99,9 @@ export interface StorageHost { /** * 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 `answer: "error_body"` or the service's `status`, since a - * refusal that names no answer from the service reads as an incompatible response. + * 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", @@ -175,15 +176,17 @@ export interface Credentials { * 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. 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. + * 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. + * 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, @@ -193,7 +196,9 @@ export interface Credentials { * 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. + * 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, diff --git a/packages/sdk/src/implementation/oauth.ts b/packages/sdk/src/implementation/oauth.ts index 4a2fd2e713..92af812e36 100644 --- a/packages/sdk/src/implementation/oauth.ts +++ b/packages/sdk/src/implementation/oauth.ts @@ -129,11 +129,14 @@ const retryAfterOf = (reason: string, error: OAuthProtocolFailed) => /** * The tokens a credentials store's renewal returned. RFC 6749 §5.1 gives `expires_in` as a - * number; any other shape is a response Executor cannot use. `project` drops host-only members - * before anything reaches the account. + * number, and some services send it as a numeric string, which oauth4webapi accepts on the + * host's own request; any other shape is a response Executor cannot use. `project` drops + * host-only members before anything reaches the account. */ const RenewedTokens = Schema.StructWithRest( - Schema.Struct({ expires_in: Schema.optional(Schema.Number) }), + Schema.Struct({ + expires_in: Schema.optional(Schema.Union([Schema.Number, Schema.FiniteFromString])), + }), [JsonObject], ); const renewedTokens = (tokens: Redacted.Redacted) => From 5ecc47a84c40bcb3aa884010c34fe6b848c39139 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:39:03 +0200 Subject: [PATCH 5/5] Normalize a store's renewed tokens as the host normalizes its own A credentials store that passed the service's body as it came, with expires_in: null or a scope array, failed renewal before the resealed grant was saved, so a rotated refresh token was lost. The store path now runs the tokens through the same normalizedTokens the host applies to its own token response, and refuses a negative expires_in as invalid_response as oauth4webapi does on the host path instead of reading it as no lifetime. --- .../sdk/src/implementation/oauth-protocol.ts | 2 +- packages/sdk/src/implementation/oauth.ts | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/sdk/src/implementation/oauth-protocol.ts b/packages/sdk/src/implementation/oauth-protocol.ts index 4fd6926189..03e0e26ab0 100644 --- a/packages/sdk/src/implementation/oauth-protocol.ts +++ b/packages/sdk/src/implementation/oauth-protocol.ts @@ -725,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 = {}; for (const [key, value] of Object.entries(body)) { if (optionalTokenMembers.has(key) && (value === null || (value === "" && key !== "scope"))) diff --git a/packages/sdk/src/implementation/oauth.ts b/packages/sdk/src/implementation/oauth.ts index 92af812e36..75437b4657 100644 --- a/packages/sdk/src/implementation/oauth.ts +++ b/packages/sdk/src/implementation/oauth.ts @@ -75,6 +75,7 @@ import { idTokenIdentity, isOAuthErrorResponse, makeOAuthProtocol, + normalizedTokens, OAuthProtocolFailed, } from "./oauth-protocol.ts"; import { defaultLabel, ownedAccount } from "./accounts.ts"; @@ -128,19 +129,25 @@ const retryAfterOf = (reason: string, error: OAuthProtocolFailed) => : {}; /** - * The tokens a credentials store's renewal returned. RFC 6749 §5.1 gives `expires_in` as a - * number, and some services send it as a numeric string, which oauth4webapi accepts on the - * host's own request; any other shape is a response Executor cannot use. `project` drops - * host-only members before anything reaches the account. + * The tokens a credentials store's renewal returned, normalized as the host normalizes its own + * token response, so a store may pass the service's body as it came. RFC 6749 §5.1 gives + * `expires_in` as a non-negative number, and some services send it as a numeric string, which + * oauth4webapi accepts on the host's own request; any other shape, including a negative + * lifetime, is a response Executor cannot use. `project` drops host-only members before + * anything reaches the account. */ const RenewedTokens = Schema.StructWithRest( Schema.Struct({ - expires_in: Schema.optional(Schema.Union([Schema.Number, Schema.FiniteFromString])), + expires_in: Schema.optional( + Schema.Union([Schema.Number, Schema.FiniteFromString]).check( + Schema.isGreaterThanOrEqualTo(0), + ), + ), }), [JsonObject], ); const renewedTokens = (tokens: Redacted.Redacted) => - Schema.decodeUnknownEffect(RenewedTokens)(Redacted.value(tokens)).pipe( + Schema.decodeUnknownEffect(RenewedTokens)(normalizedTokens(Redacted.value(tokens))).pipe( Effect.mapError( () => new OAuthProtocolFailed({ reason: "invalid_response", code: "schema_decode" }), ),