From f98ebed6007d0ff5456023c6aabb91ddff8da00a Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:36:06 +0200 Subject: [PATCH] feat(sdk): let a credential provider perform the OAuth refresh grant itself Executor performed the OAuth refresh exchange itself, so it had to ask the credential provider for the stored refresh token and the client secret, which a sealed store cannot hand over. CredentialProvider gains an optional refreshGrant: the provider spends the refresh token, seals the new tokens under the same item ids and returns only lifetime and scope, which the host re-validates. Providers without it, client_credentials grants and first-party apps keep the existing host path unchanged. --- .../provider-owned-oauth-refresh-grant.md | 7 + packages/core/sdk/src/executor.ts | 396 ++++++- packages/core/sdk/src/index.ts | 13 +- .../oauth-refresh-grant-delegation.test.ts | 1003 +++++++++++++++++ packages/core/sdk/src/promise.ts | 13 +- packages/core/sdk/src/provider.ts | 133 ++- packages/core/sdk/src/shared.ts | 13 +- 7 files changed, 1524 insertions(+), 54 deletions(-) create mode 100644 .changeset/provider-owned-oauth-refresh-grant.md create mode 100644 packages/core/sdk/src/oauth-refresh-grant-delegation.test.ts diff --git a/.changeset/provider-owned-oauth-refresh-grant.md b/.changeset/provider-owned-oauth-refresh-grant.md new file mode 100644 index 0000000000..f5c49a68f0 --- /dev/null +++ b/.changeset/provider-owned-oauth-refresh-grant.md @@ -0,0 +1,7 @@ +--- +"@executor-js/sdk": minor +--- + +`CredentialProvider` gains an optional `refreshGrant`. When a provider implements it, Executor asks it to perform the OAuth refresh exchange instead of asking it for the refresh token: the provider spends the token, seals the newly minted access token (and a rotated refresh token, if the authorization server sent one) under the same item ids, and reports only the granted lifetime and scope. Executor re-validates that metadata, reads the access token back through `get` like any other credential, and never resolves the refresh token or the client secret on that path. + +A refused grant comes back as `RefreshGrantRejected` carrying a closed standards-defined token-endpoint code, so a delegated refusal classifies re-authentication, surfaces `invalid_grant` to the caller, and arms the known-dead gate exactly as a host-side refusal does. Providers that do not implement `refreshGrant` are unaffected, and so are the grants that have no refresh token to delegate. diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index fdbc9b7671..4f9a517109 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -166,7 +166,13 @@ import { type ToolPolicy, type UpdateToolPolicyInput, } from "./policies"; -import type { CredentialProvider, ProviderEntry } from "./provider"; +import { + MAX_REFRESH_GRANT_EXPIRES_IN_SECONDS, + isRefreshGrantRejectionCode, + type CredentialProvider, + type ProviderEntry, + type RefreshGrantRejected, +} from "./provider"; import { touchSubject } from "./subject-registry"; import type { AnyPlugin, @@ -208,7 +214,9 @@ import { collectReferencedDefinitions } from "./schema-refs"; import { refreshAccessToken, exchangeClientCredentials, + DEFAULT_CLIENT_AUTH_METHOD, isPermanentTokenRejection, + isSupportedOAuthEndpointUrl, isUnusableSuccessTokenResponse, optionalScopesFromAuthorizationUrl, shouldRefreshToken, @@ -2269,12 +2277,35 @@ export const createExecutor = [`first-party:${client.name}`, client]), ); + /** Where the app's client secret lives — carried UNRESOLVED so the refresh + * path can decide what to do with it before opening it. A provider that + * performs the grant itself is handed the item id and never the value, and + * resolving it eagerly would fail the whole refresh against a store that + * seals this item, before the provider that can spend it without revealing + * it was ever consulted. */ + type RefreshClientSecret = + /** Public client: there is no secret to present. */ + | { readonly kind: "none" } + /** Sealed in the credential provider under this item id. */ + | { + readonly kind: "item"; + readonly itemId: string; + /** The failure to report when the item resolves to nothing because a + * credential write never completed — distinct from an app that + * genuinely has no secret. `null` when no write was in flight. */ + readonly incompleteWriteMessage: string | null; + } + /** A first-party app configured on the host. The value is already in this + * process and never came from a provider, so there is no id to name it + * by and nothing for a delegated grant to be spared from reading. */ + | { readonly kind: "host"; readonly value: string }; + /** The app identity a refresh runs against, uniformly resolved: a stored * row's secret comes out of the credential provider by item id; a * first-party app's comes from host config and never touches a provider. */ interface RefreshClient { readonly clientId: string; - readonly clientSecret: string; + readonly clientSecret: RefreshClientSecret; readonly tokenUrl: string; readonly grant: string; readonly resource: string | null; @@ -2282,6 +2313,28 @@ export const createExecutor = => + secret.kind === "none" + ? Effect.succeed("") + : secret.kind === "host" + ? Effect.succeed(secret.value) + : Effect.gen(function* () { + const resolved = yield* provider.get(ProviderItemId.make(secret.itemId)); + if (resolved === null && secret.incompleteWriteMessage !== null) { + return yield* new StorageError({ + message: secret.incompleteWriteMessage, + cause: undefined, + }); + } + return resolved ?? ""; + }); + /** What drove a refresh: the pre-call expiry check (`proactive`), or an * upstream 401 on a token we believed was still valid (`reactive`). */ type RefreshTrigger = "proactive" | "reactive"; @@ -2362,6 +2415,42 @@ export const createExecutor = + ProviderItemId.make( + connectionItemIds(row)[PRIMARY_INPUT_VARIABLE] ?? + `connection:${row.owner}:${row.integration}:${row.name}:${PRIMARY_INPUT_VARIABLE}`, + ); + + /** Stamp what a refresh produced onto the connection row. Shared by the + * host-side exchange and the delegated one so their bookkeeping cannot + * drift apart. `scope` is written only when the authorization server + * reported one — an unreported scope must leave the recorded scope alone + * rather than clearing it. */ + const recordRefreshOutcome = ( + row: ConnectionRow, + expiresAt: number | null, + scope: string | undefined, + ): Effect.Effect => + Effect.gen(function* () { + const set: Record = { + expires_at: expiresAt, + updated_at: new Date(), + }; + if (scope !== undefined) set.oauth_scope = scope; + yield* core.updateMany("connection", { + where: (b: AnyCb) => + b.and( + byOwner(row.owner as Owner)(b), + b("integration", "=", String(row.integration)), + b("name", "=", String(row.name)), + ), + set, + }); + }); + const persistRefreshedToken = ( row: ConnectionRow, provider: CredentialProvider, @@ -2370,11 +2459,6 @@ export const createExecutor = => Effect.gen(function* () { if (provider.set) { - // OAuth is always single-input: the access token lives in the `token` - // item. Fall back to a deterministic id if the map is somehow empty. - const tokenItemId = - connectionItemIds(row)[PRIMARY_INPUT_VARIABLE] ?? - `connection:${row.owner}:${row.integration}:${row.name}:${PRIMARY_INPUT_VARIABLE}`; if ( token.refresh_token && row.refresh_item_id && @@ -2382,25 +2466,12 @@ export const createExecutor = = { - expires_at: nextExpiresAt, - updated_at: new Date(), - }; - if (token.scope !== undefined) set.oauth_scope = token.scope; - yield* core.updateMany("connection", { - where: (b: AnyCb) => - b.and( - byOwner(row.owner as Owner)(b), - b("integration", "=", String(row.integration)), - b("name", "=", String(row.name)), - ), - set, - }); + yield* recordRefreshOutcome(row, nextExpiresAt, token.scope); }); /** The rendered message of a typed enterprise-managed failure. */ @@ -2439,6 +2510,16 @@ export const createExecutor = => + Predicate.isTagged(error, "CredentialResolutionError") && error.reauthRequired === true + ? // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: CredentialResolutionError carries a typed `message` field + markRefreshGrantDead(row, error.message, credentialFailureReason(error)) + : Effect.void; + // Refresh against the region the code was redeemed at when one was // recorded at connect time (multi-site providers like Datadog), else // the oauth_client's configured token endpoint. @@ -2730,6 +2828,213 @@ export const createExecutor = + new StorageError({ + message: "Credential provider could not complete OAuth token refresh.", + cause: undefined, + }); + const preserveProviderInterruption = ( + cause: Cause.Cause, + ): Effect.Effect => + Effect.failCause(Cause.fromReasons(cause.reasons.filter(Cause.isInterruptReason))); + // A provider is a credential boundary, so never project its Error + // message/cause (or an unrecognised `error` value) into host errors. + // Those values may contain token responses or other secret material. + // The closed standards-defined code is the only provider-controlled + // value allowed to reach callers, health persistence, or span + // attributes. + const classifyProviderGrantRefusal = ( + cause: RefreshGrantRejected, + ): CredentialResolutionError | StorageError => { + const reportedError = cause.error; + const error = isRefreshGrantRejectionCode(reportedError) ? reportedError : undefined; + return error !== undefined + ? new CredentialResolutionError({ + owner, + integration: IntegrationSlug.make(row.integration), + name: ConnectionName.make(row.name), + message: `OAuth token refresh was rejected (${error}).`, + reauthRequired: error === "invalid_grant", + oauthErrorCode: error, + }) + : providerRefreshFailure(); + }; + const delegatedRefreshGrant = + clientRow.grant === "client_credentials" || clientRow.clientSecret.kind === "host" + ? undefined + : yield* Effect.suspend(() => Effect.succeed(provider.refreshGrant)).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? preserveProviderInterruption(cause) + : Effect.fail(providerRefreshFailure()), + ), + ); + if (delegatedRefreshGrant !== undefined) { + if (!row.refresh_item_id) { + return yield* reauth("No refresh token is stored for this connection.", { + credentialMissing: true, + }); + } + // Delegating the exchange must not delegate the guard: the endpoint + // policy is the HOST's, so enforce it here rather than trusting every + // provider to reimplement it. + if (!isSupportedOAuthEndpointUrl(tokenUrl, config.oauthEndpointUrlPolicy)) { + return yield* reauth( + `OAuth token URL "${tokenUrl}" must use https: or loopback http:.`, + ); + } + // Named for the provider to seal into, and read back from afterwards. + const tokenItemId = connectionTokenItemId(row); + const clientSecretItemId = + clientRow.clientSecret.kind === "item" ? clientRow.clientSecret.itemId : undefined; + const granted = yield* Effect.suspend(() => + delegatedRefreshGrant.call(provider, { + refreshItemId: ProviderItemId.make(String(row.refresh_item_id)), + accessItemId: tokenItemId, + ...(clientSecretItemId === undefined + ? {} + : { clientSecretItemId: ProviderItemId.make(clientSecretItemId) }), + tokenUrl, + clientId: clientRow.clientId, + // The method RECORDED on this app, so the delegated request + // presents the secret exactly as the host-side exchange would. + clientAuth: clientRow.tokenEndpointAuthMethod ?? DEFAULT_CLIENT_AUTH_METHOD, + ...(clientRow.tokenRequestFormat === undefined + ? {} + : { requestFormat: clientRow.tokenRequestFormat }), + scopes: grantedScopes, + // RFC 8707: keep the re-minted token bound to the same resource. + ...(clientRow.resource ? { resource: String(clientRow.resource) } : {}), + }), + ).pipe( + // Project the success value while it is still inside the guarded provider boundary. + // Accessors on a remote/plugin object can throw, and an arbitrary scope string would + // otherwise be a direct channel into persisted host state. Rebuild scope exclusively + // from the host's already-trusted grant set. + Effect.flatMap((result) => + Effect.suspend(() => { + const expiresInSeconds = result.expiresInSeconds; + const reportedScope = result.scope; + if ( + expiresInSeconds !== null && + (typeof expiresInSeconds !== "number" || + !Number.isFinite(expiresInSeconds) || + expiresInSeconds < 0 || + expiresInSeconds > MAX_REFRESH_GRANT_EXPIRES_IN_SECONDS) + ) { + return Effect.fail(providerRefreshFailure()); + } + if (reportedScope !== null && typeof reportedScope !== "string") { + return Effect.fail(providerRefreshFailure()); + } + const trustedScopes = new Map(grantedScopes.map((scope) => [scope, scope])); + const reportedScopes = + reportedScope === null + ? null + : [...new Set(reportedScope.split(/\s+/).filter(Boolean))]; + // With no recorded grant there is nothing to validate against — and nothing to + // widen FROM either, since the request omits the scope parameter entirely. Failing + // here would strand a legitimate connection in a permanent retry loop, because + // RFC 6749 §5.1 lets an authorization server omit the scope it granted. So keep + // the refresh and simply record no scope: the reported value is still never + // persisted, which is the property this validation exists to hold. + if (trustedScopes.size === 0) { + return Effect.succeed({ expiresInSeconds, scope: null }); + } + if ( + reportedScopes !== null && + reportedScopes.some((scope) => !trustedScopes.has(scope)) + ) { + return Effect.fail(providerRefreshFailure()); + } + return Effect.succeed({ + expiresInSeconds, + scope: + reportedScopes === null + ? null + : reportedScopes.map((scope) => trustedScopes.get(scope)!).join(" "), + }); + }), + ), + // This is an external plugin boundary. Preserve cancellation, but discard every + // provider-authored failure/defect before it can reach Cause.pretty, traces, or logs. + Effect.catchCause((cause) => { + if (Cause.hasInterrupts(cause)) return preserveProviderInterruption(cause); + const reason = cause.reasons.length === 1 ? cause.reasons[0] : undefined; + return Effect.suspend(() => + Effect.succeed( + reason !== undefined && + Cause.isFailReason(reason) && + Predicate.isTagged(reason.error, "RefreshGrantRejected") + ? classifyProviderGrantRefusal(reason.error) + : providerRefreshFailure(), + ), + ).pipe( + // Even a malformed tagged object may throw from `_tag`/`error` accessors. + Effect.catchCause((classificationCause) => + Cause.hasInterrupts(classificationCause) + ? preserveProviderInterruption(classificationCause) + : Effect.succeed(providerRefreshFailure()), + ), + Effect.flatMap((error) => Effect.fail(error)), + ); + }), + Effect.tapError(armKnownDeadGate), + ); + // Read the token back BEFORE recording success. A provider that + // reported a grant it did not actually seal would otherwise leave the + // row stamped with a fresh expiry over a stale or absent token, and + // the connection would read healthy for a whole token lifetime while + // every call using it failed. + const access = yield* Effect.suspend(() => provider.get(tokenItemId)).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? preserveProviderInterruption(cause) + : Effect.fail( + new StorageError({ + message: "Credential provider could not resolve the refreshed access token.", + cause: undefined, + }), + ), + ), + ); + if (typeof access !== "string" || access.length === 0) { + return yield* new StorageError({ + message: "Credential provider did not make the refreshed access token resolvable.", + cause: undefined, + }); + } + // Convert on OUR clock, never the provider's — `shouldRefreshToken` + // compares the stored instant against this same clock, so an absolute + // instant computed on a remote machine would import its skew. + yield* recordRefreshOutcome( + row, + granted.expiresInSeconds === null ? null : Date.now() + granted.expiresInSeconds * 1000, + granted.scope ?? undefined, + ); + return access; + } + + // Opened only now, BELOW the delegated branch: a store that seals this + // item would otherwise fail the whole refresh here, before the provider + // that can spend it without ever revealing it was even consulted. + const clientSecret = yield* resolveRefreshClientSecret(provider, clientRow.clientSecret); + // client_credentials (machine-to-machine) has NO refresh token — the // token is RE-MINTED from the client id/secret. The authorization_code // path below needs a stored refresh token. Branching on grant here is @@ -2876,16 +3181,7 @@ export const createExecutor = - Predicate.isTagged(error, "CredentialResolutionError") && - error.reauthRequired === true - ? // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: CredentialResolutionError carries a typed `message` field - markRefreshGrantDead(row, error.message, credentialFailureReason(error)) - : Effect.void, - ), + Effect.tapError(armKnownDeadGate), ); }); diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 627d3de1de..f033a9834e 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -109,7 +109,18 @@ export type { } from "./connection"; export type { Tool, ToolDef, ToolListFilter, ToolAnnotations } from "./tool"; // Credential providers. -export type { CredentialProvider, ProviderEntry } from "./provider"; +export { + MAX_REFRESH_GRANT_EXPIRES_IN_SECONDS, + RefreshGrantRejected, + isRefreshGrantRejectionCode, +} from "./provider"; +export type { + CredentialProvider, + ProviderEntry, + RefreshGrantInput, + RefreshGrantRejectionCode, + RefreshGrantResult, +} from "./provider"; // Public projections / detection. export { ToolSchemaView, ToolAnnotationsView, IntegrationDetectionResult } from "./types"; diff --git a/packages/core/sdk/src/oauth-refresh-grant-delegation.test.ts b/packages/core/sdk/src/oauth-refresh-grant-delegation.test.ts new file mode 100644 index 0000000000..9d6b272332 --- /dev/null +++ b/packages/core/sdk/src/oauth-refresh-grant-delegation.test.ts @@ -0,0 +1,1003 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit } from "effect"; + +import { StorageError } from "./fuma-runtime"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, + ProviderItemId, + ProviderKey, + ToolAddress, + ToolName, +} from "./ids"; +import { firstPartyOAuthClientSlug, type TokenEndpointAuthMethod } from "./oauth-client"; +import { definePlugin } from "./plugin"; +import { + MAX_REFRESH_GRANT_EXPIRES_IN_SECONDS, + RefreshGrantRejected, + type CredentialProvider, + type RefreshGrantInput, + type RefreshGrantRejectionCode, +} from "./provider"; +import { makeTestWorkspaceHarness } from "./test-config"; +import { serveOAuthTestServer } from "./testing/oauth-test-server"; + +// A provider that OWNS the refresh grant never hands the refresh token out. These tests pin that +// property directly rather than asserting "the refresh succeeded" — success is not the claim. The +// claim is that the host never resolved the secret, and only a test watching `get` can tell a +// provider that protected the token from one that quietly served it. Both would go green. + +const INTEG = IntegrationSlug.make("acme"); +const TEMPLATE = AuthTemplateSlug.make("oauth"); +const CLIENT = OAuthClientSlug.make("acme-app"); +const FIRST_PARTY = firstPartyOAuthClientSlug("acme"); +const TOOL = ToolAddress.make("tools.acme.org.main.whoami"); +const TOKEN_CANARY = "refresh-token-canary-must-never-cross-the-provider-boundary"; + +const oauthPlugin = definePlugin(() => ({ + id: "acme" as const, + storage: () => ({}), + resolveTools: () => + Effect.succeed({ tools: [{ name: ToolName.make("whoami"), description: "whoami" }] }), + describeAuthMethods: (record) => { + const config = record.config as { readonly scopes?: readonly string[] } | null; + return [ + { + id: "oauth", + label: "OAuth2", + kind: "oauth" as const, + template: String(TEMPLATE), + oauth: { scopes: config?.scopes ?? [] }, + }, + ]; + }, + invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }), + extension: (ctx) => ({ + seed: (scopes: readonly string[] = []) => + ctx.core.integrations.register({ slug: INTEG, description: "Acme", config: { scopes } }), + }), +}))(); + +/** What the delegating provider does when the host asks it to perform the grant. */ +type GrantBehaviour = + /** The honest implementation: seal a new access token, report expiry and scope. */ + | { + readonly kind: "seals"; + readonly scope: string | null; + readonly expiresInSeconds: number | null; + } + /** Reports success but leaves nothing resolvable under `accessItemId`. */ + | { readonly kind: "sealsNothing" } + /** The authorization server refused the grant (RFC 6749 §5.2). */ + | { + readonly kind: "rejected"; + readonly error?: RefreshGrantRejectionCode; + /** Test-only hostile fields a JavaScript/remote provider could attach despite the type. */ + readonly unsafeDetails?: string; + readonly unsafeError?: string; + } + /** A provider-side storage failure whose diagnostic details must remain provider-side. */ + | { readonly kind: "storageFailure" } + /** A provider implementation that throws before it can return an Effect. */ + | { readonly kind: "syncThrow" } + /** A provider implementation that dies inside its Effect. */ + | { readonly kind: "defect" } + /** A concurrent provider failure that contains cancellation plus a secret-bearing defect. */ + | { readonly kind: "interruptedDefect" } + /** A malformed remote provider object whose classification getter throws. */ + | { readonly kind: "throwingRejectionGetter" } + /** A stateful rejection getter that changes after returning one valid code. */ + | { readonly kind: "changingRejectionGetter" } + /** Reading the optional capability itself throws before a grant can start. */ + | { readonly kind: "throwingCapabilityGetter" } + /** A success object whose property access throws after the provider Effect succeeds. */ + | { readonly kind: "throwingResultGetter"; readonly field: "expiry" | "scope" } + /** A method-shaped provider that relies on its receiver. */ + | { readonly kind: "requiresReceiver" } + /** A successful grant followed by a failure while resolving the new access token. */ + | { readonly kind: "readFailure"; readonly failure: "storage" | "defect" }; + +const SEALS: GrantBehaviour = { kind: "seals", scope: "read", expiresInSeconds: 3_600 }; + +/** Records what the host asked the provider for, so a test can assert what it did NOT ask for. */ +interface Recorder { + readonly reads: string[]; + readonly grants: RefreshGrantInput[]; + rejectionErrorReads: number; +} + +/** A memory provider that can also perform the refresh grant itself. + * + * `refreshGrant` seals under `accessItemId` exactly as a sealed-store provider would, and returns + * only expiry and scope. It never calls `get`. `behaviour: null` omits the capability entirely, + * which is how the fallback test shows the difference is the capability and not the harness. */ +const delegatingCredentialsPlugin = (recorder: Recorder, behaviour: GrantBehaviour | null) => + definePlugin(() => { + const store = new Map(); + + const base = { + key: ProviderKey.make("memory"), + writable: true as const, + get: (id: ProviderItemId) => + Effect.suspend(() => { + recorder.reads.push(String(id)); + if ( + behaviour?.kind === "readFailure" && + recorder.grants.length > 0 && + recorder.grants.some((grant) => String(grant.accessItemId) === String(id)) + ) { + return behaviour.failure === "storage" + ? Effect.fail( + new StorageError({ + message: TOKEN_CANARY, + cause: { tokenResponse: TOKEN_CANARY }, + }), + ) + : Effect.die( + // oxlint-disable-next-line executor/no-error-constructor -- boundary: leak test deliberately injects a raw provider defect + new Error(TOKEN_CANARY), + ); + } + return Effect.succeed(store.get(String(id)) ?? null); + }), + set: (id: ProviderItemId, value: string) => + Effect.sync(() => { + store.set(String(id), value); + }), + delete: (id: ProviderItemId) => + Effect.sync(() => { + store.delete(String(id)); + }), + }; + + const provider: CredentialProvider = + behaviour === null + ? base + : behaviour.kind === "throwingCapabilityGetter" + ? (Object.defineProperty({ ...base }, "refreshGrant", { + get: () => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: leak test simulates an untyped plugin capability getter + throw TOKEN_CANARY; + }, + }) as CredentialProvider) + : { + ...base, + refreshGrant(input: RefreshGrantInput) { + recorder.grants.push(input); + if (behaviour.kind === "requiresReceiver" && this.key !== base.key) { + return Effect.die(TOKEN_CANARY); + } + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: leak test simulates an untyped plugin throwing before it returns an Effect + if (behaviour.kind === "syncThrow") throw new Error(TOKEN_CANARY); + if (behaviour.kind === "storageFailure") { + return Effect.fail( + new StorageError({ + message: TOKEN_CANARY, + cause: { tokenResponse: TOKEN_CANARY }, + }), + ); + } + if (behaviour.kind === "defect") { + // oxlint-disable-next-line executor/no-error-constructor -- boundary: leak test deliberately injects a raw provider defect + return Effect.die(new Error(TOKEN_CANARY)); + } + if (behaviour.kind === "interruptedDefect") { + return Effect.failCause( + Cause.combine(Cause.die(TOKEN_CANARY), Cause.interrupt(123)), + ); + } + if (behaviour.kind === "throwingRejectionGetter") { + const malformed = Object.defineProperty( + { _tag: "RefreshGrantRejected" }, + "error", + { + get: () => { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: leak test simulates a malformed remote object with a throwing getter + throw new Error(TOKEN_CANARY); + }, + }, + ); + return Effect.fail(malformed as RefreshGrantRejected); + } + if (behaviour.kind === "changingRejectionGetter") { + const malformed = Object.defineProperty( + { _tag: "RefreshGrantRejected" }, + "error", + { + get: () => { + recorder.rejectionErrorReads += 1; + return recorder.rejectionErrorReads === 1 ? "invalid_grant" : TOKEN_CANARY; + }, + }, + ); + return Effect.fail(malformed as RefreshGrantRejected); + } + if (behaviour.kind === "throwingResultGetter") { + const result = { expiresInSeconds: 3_600, scope: "read" }; + Object.defineProperty( + result, + behaviour.field === "expiry" ? "expiresInSeconds" : "scope", + { + get: () => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: leak test simulates a malformed remote success object + throw TOKEN_CANARY; + }, + }, + ); + return Effect.succeed(result); + } + return Effect.suspend(() => { + if (behaviour.kind === "rejected") { + const rejection = new RefreshGrantRejected( + behaviour.error === undefined ? {} : { error: behaviour.error }, + ); + if ( + behaviour.unsafeDetails === undefined && + behaviour.unsafeError === undefined + ) { + return Effect.fail(rejection); + } + // Simulate an untyped JavaScript or remote provider. Executor must project only + // the validated RFC classification, even when extra secret-bearing fields exist. + return Effect.fail( + Object.assign(rejection, { + error: behaviour.unsafeError ?? rejection.error, + message: behaviour.unsafeDetails, + cause: { message: behaviour.unsafeDetails }, + }), + ); + } + if (behaviour.kind === "sealsNothing") { + // A provider reporting a grant it did not perform is out of contract. What the + // host CAN do is refuse to stamp the row healthy over a token it cannot read + // back, which is what this drives. + store.delete(String(input.accessItemId)); + return Effect.succeed({ expiresInSeconds: 3_600, scope: "read" }); + } + store.set(String(input.accessItemId), "delegated-access-token"); + return Effect.succeed({ + expiresInSeconds: + behaviour.kind === "readFailure" || behaviour.kind === "requiresReceiver" + ? 3_600 + : behaviour.expiresInSeconds, + scope: + behaviour.kind === "readFailure" || behaviour.kind === "requiresReceiver" + ? "read" + : behaviour.scope, + }); + }); + }, + }; + + return { + id: "memory-credentials" as const, + storage: () => ({}), + credentialProviders: [provider], + }; + })(); + +describe("provider-owned OAuth refresh grant", () => { + const expectOpaqueProviderFailure = (exit: Exit.Exit, message: string) => { + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + expect(JSON.stringify(exit)).not.toContain(TOKEN_CANARY); + expect(Cause.pretty(exit.cause)).not.toContain(TOKEN_CANARY); + const reason = exit.cause.reasons.find(Cause.isFailReason); + expect(reason).toBeDefined(); + if (reason === undefined) return; + expect(reason.error).toBeInstanceOf(StorageError); + expect((reason.error as StorageError).message).toBe(message); + expect((reason.error as StorageError).cause).toBeUndefined(); + }; + + /** Connect, force the connection past expiry, and hand back the pieces a test asserts on. The + * tool is NOT invoked here — each test drives the refresh itself so it can assert on failure. */ + const scenario = (options: { + readonly behaviour: GrantBehaviour | null; + readonly grant?: "authorization_code" | "client_credentials"; + readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod; + /** Back the connection with a config-declared app whose secret is host config, not a + * provider item. */ + readonly firstParty?: boolean; + }) => + Effect.gen(function* () { + const recorder: Recorder = { reads: [], grants: [], rejectionErrorReads: 0 }; + const server = yield* serveOAuthTestServer({ + scopes: ["read"], + ...(options.tokenEndpointAuthMethod === "basic" + ? { defaultTokenEndpointAuthMethod: "client_secret_basic" as const } + : {}), + }); + const plugins = [ + delegatingCredentialsPlugin(recorder, options.behaviour), + oauthPlugin, + ] as const; + const { executor, config } = yield* makeTestWorkspaceHarness({ + plugins, + ...(options.firstParty === true + ? { + firstPartyOAuthClients: [ + { + name: "acme", + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + clientId: "test-client", + clientSecret: "test-secret", + integrations: [INTEG], + }, + ], + } + : {}), + }); + yield* executor.acme.seed(["read"]); + + const grant = options.grant ?? "authorization_code"; + if (options.firstParty !== true) { + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant, + clientId: "test-client", + clientSecret: "test-secret", + ...(options.tokenEndpointAuthMethod === undefined + ? {} + : { tokenEndpointAuthMethod: options.tokenEndpointAuthMethod }), + }); + } + + const started = yield* executor.oauth.start({ + owner: "org", + client: options.firstParty === true ? FIRST_PARTY : CLIENT, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + + // `die` rather than `expect` — this is shared setup, not the assertion under test, and an + // expect inside a branch is what the repo's no-conditional-tests rule exists to stop. + if (grant === "authorization_code") { + if (started.status !== "redirect") { + return yield* Effect.die("expected a redirect-status OAuth start"); + } + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + yield* executor.oauth.complete({ state: started.state, code: callback.code }); + } else if (started.status !== "connected") { + return yield* Effect.die("expected client_credentials to connect without a redirect"); + } + + // Force the next resolve down the refresh path. + yield* Effect.promise(() => + config.db.updateMany("connection", { + where: (b) => b("name", "=", "main"), + set: { expires_at: Date.now() - 60_000 }, + }), + ); + + recorder.reads.length = 0; + recorder.grants.length = 0; + yield* server.clearRequests; + return { recorder, server, config, executor }; + }); + + /** Refresh-token grants the HOST posted to the authorization server. Zero of them is the other + * half of the custody claim: not only was the token never resolved, it was never spent here. */ + const hostRefreshRequests = (server: { + readonly requests: Effect.Effect; + }) => + server.requests.pipe( + Effect.map((requests) => + requests.filter( + (request) => + request.path === "/token" && request.body.includes("grant_type=refresh_token"), + ), + ), + ); + + it.effect("delegates the grant and never resolves the refresh token through the host", () => + Effect.scoped( + Effect.gen(function* () { + const { recorder, server, executor } = yield* scenario({ behaviour: SEALS }); + const out = yield* executor.execute(TOOL, {}); + + // The grant was delegated, and named by id rather than handed a value. + expect(recorder.grants).toHaveLength(1); + const grant = recorder.grants[0]!; + expect(String(grant.refreshItemId)).toContain(":refresh"); + expect(grant.tokenUrl).toBe(server.tokenEndpoint); + expect(grant.clientAuth).toBe("body"); + expect(grant.scopes).toEqual(["read"]); + + // THE CUSTODY CLAIM. If this ever fails, the host is asking for the secret again and the + // guarantee is gone — while the refresh itself still appears to work. + expect(recorder.reads.some((id) => id.endsWith(":refresh"))).toBe(false); + + // The client secret is a long-lived credential too, and the provider was given its ID + // precisely so it need never be revealed. Resolving it anyway would leave a sealed store + // failing the refresh before `refreshGrant` was ever reached. + expect(recorder.reads.some((id) => id.includes("secret"))).toBe(false); + expect(grant.clientSecretItemId).toBeDefined(); + + // And the host never spent the grant itself: no refresh-token request reached the + // authorization server from this process. + expect(yield* hostRefreshRequests(server)).toHaveLength(0); + + // The token the tool ran with is the one the provider sealed. + expect(out).toEqual({ token: "delegated-access-token" }); + }), + ), + ); + + it.effect("falls back to the host-side exchange when the provider cannot do the grant", () => + Effect.scoped( + Effect.gen(function* () { + const { recorder, server, executor } = yield* scenario({ behaviour: null }); + yield* executor.execute(TOOL, {}); + + // Absence of `refreshGrant` changes nothing: the host performs the exchange, so it DOES + // resolve the refresh token. Pinning that here is what makes the test above meaningful — + // it shows the difference is the provider capability, not the harness. + expect(recorder.grants).toHaveLength(0); + expect(recorder.reads.some((id) => id.endsWith(":refresh"))).toBe(true); + expect(yield* hostRefreshRequests(server)).toHaveLength(1); + }), + ), + ); + + it.effect("hands the provider the client auth method recorded on the app", () => + Effect.scoped( + Effect.gen(function* () { + const { recorder, executor } = yield* scenario({ + behaviour: SEALS, + tokenEndpointAuthMethod: "basic", + }); + yield* executor.execute(TOOL, {}); + + // The host records a negotiated method per app and presents the secret that way on its own + // exchange. Telling the provider "body" regardless would make a delegated refresh fail on + // every client_secret_basic app — a guess where a recorded answer exists. + expect(recorder.grants).toHaveLength(1); + expect(recorder.grants[0]?.clientAuth).toBe("basic"); + }), + ), + ); + + it.effect("records the expiry and scope the provider reported", () => + Effect.scoped( + Effect.gen(function* () { + const before = Date.now(); + const { config, executor } = yield* scenario({ behaviour: SEALS }); + yield* executor.execute(TOOL, {}); + + const row = yield* Effect.promise(() => + config.db.findFirst("connection", { where: (b) => b("name", "=", "main") }), + ); + // Converted against the HOST clock, so the stored instant is comparable with the one + // `shouldRefreshToken` later reads. A provider-computed absolute instant would import that + // machine's skew and either serve expired tokens or churn. + expect(Number(row?.expires_at)).toBeGreaterThanOrEqual(before + 3_600_000); + expect(Number(row?.expires_at)).toBeLessThanOrEqual(Date.now() + 3_600_000); + expect(row?.oauth_scope).toBe("read"); + }), + ), + ); + + it.effect("accepts the documented maximum delegated token lifetime", () => + Effect.scoped( + Effect.gen(function* () { + const before = Date.now(); + const { config, executor } = yield* scenario({ + behaviour: { + kind: "seals", + scope: "read", + expiresInSeconds: MAX_REFRESH_GRANT_EXPIRES_IN_SECONDS, + }, + }); + yield* executor.execute(TOOL, {}); + const row = yield* Effect.promise(() => + config.db.findFirst("connection", { where: (b) => b("name", "=", "main") }), + ); + expect(Number(row?.expires_at)).toBeGreaterThanOrEqual( + before + MAX_REFRESH_GRANT_EXPIRES_IN_SECONDS * 1_000, + ); + }), + ), + ); + + it.effect("rejects a delegated token lifetime above the documented maximum", () => + Effect.scoped( + Effect.gen(function* () { + const { executor } = yield* scenario({ + behaviour: { + kind: "seals", + scope: "read", + expiresInSeconds: MAX_REFRESH_GRANT_EXPIRES_IN_SECONDS + 1, + }, + }); + const exit = yield* Effect.exit(executor.execute(TOOL, {})); + expectOpaqueProviderFailure( + exit, + "Credential provider could not complete OAuth token refresh.", + ); + }), + ), + ); + + it.effect("leaves the recorded scope alone when the provider reports none", () => + Effect.scoped( + Effect.gen(function* () { + const { config, executor } = yield* scenario({ + behaviour: { kind: "seals", scope: null, expiresInSeconds: null }, + }); + // Give the row a scope to preserve. Without a known prior value the assertion below cannot + // tell "left alone" from "cleared" — both would read null. + yield* Effect.promise(() => + config.db.updateMany("connection", { + where: (b) => b("name", "=", "main"), + set: { oauth_scope: "read" }, + }), + ); + yield* executor.execute(TOOL, {}); + + const row = yield* Effect.promise(() => + config.db.findFirst("connection", { where: (b) => b("name", "=", "main") }), + ); + // `null` scope means "the AS did not report one", which must not clear what was granted at + // connect time — distinct from an empty scope, which would. + expect(row?.oauth_scope).toBe("read"); + expect(row?.expires_at).toBeNull(); + }), + ), + ); + + it.effect("surfaces a refused grant as re-auth and arms the known-dead gate", () => + Effect.scoped( + Effect.gen(function* () { + const { config, executor, recorder } = yield* scenario({ + behaviour: { + kind: "rejected", + error: "invalid_grant", + unsafeDetails: TOKEN_CANARY, + }, + }); + + const failure = yield* Effect.flip(executor.execute(TOOL, {})); + // Not a StorageError: that is scrubbed to "Internal tool error [id]" at the sandbox + // boundary, so the user would never be told to reconnect. + const serializedFailure = JSON.stringify(failure); + expect(serializedFailure).toContain("invalid_grant"); + expect(serializedFailure).not.toContain(TOKEN_CANARY); + + const row = yield* Effect.promise(() => + config.db.findFirst("connection", { where: (b) => b("name", "=", "main") }), + ); + expect( + (row?.provider_state as { oauthReauthRequiredAt?: number } | null)?.oauthReauthRequiredAt, + ).toEqual(expect.any(Number)); + expect(row?.last_health).toMatchObject({ + status: "expired", + reason: "credential_refresh_rejected", + }); + expect( + JSON.stringify({ providerState: row?.provider_state, lastHealth: row?.last_health }), + ).not.toContain(TOKEN_CANARY); + + // The gate is armed, so the doomed grant is not re-sent on the next resolve. Without this + // a dead connection re-sends its dead grant on every proactive cycle, indefinitely. + recorder.grants.length = 0; + yield* Effect.flip(executor.execute(TOOL, {})); + expect(recorder.grants).toHaveLength(0); + }), + ), + ); + + it.effect("preserves RFC 8707 invalid_target as a safe actionable classification", () => + Effect.scoped( + Effect.gen(function* () { + const { config, executor, recorder } = yield* scenario({ + behaviour: { kind: "rejected", error: "invalid_target" }, + }); + const exit = yield* Effect.exit(executor.execute(TOOL, {})); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + expect(Cause.pretty(exit.cause)).toContain("invalid_target"); + expect(Cause.pretty(exit.cause)).not.toContain(TOKEN_CANARY); + const row = yield* Effect.promise(() => + config.db.findFirst("connection", { where: (b) => b("name", "=", "main") }), + ); + expect( + (row?.provider_state as { oauthReauthRequiredAt?: number } | null)?.oauthReauthRequiredAt, + ).toBeUndefined(); + + // Unlike invalid_grant, invalid_target does not prove the refresh token is dead. + recorder.grants.length = 0; + yield* Effect.exit(executor.execute(TOOL, {})); + expect(recorder.grants).toHaveLength(1); + }), + ), + ); + + it.effect("drops free-form rejection details and malformed classifications", () => + Effect.scoped( + Effect.gen(function* () { + const { config, executor, recorder } = yield* scenario({ + behaviour: { + kind: "rejected", + unsafeDetails: TOKEN_CANARY, + unsafeError: TOKEN_CANARY, + }, + }); + + const failure = yield* Effect.flip(executor.execute(TOOL, {})); + // This is also the failure object an outer boundary may log. A fixed message plus an + // undefined cause proves the hostile provider payload cannot flow through that log path. + expect(failure).toMatchObject({ + _tag: "StorageError", + message: "Credential provider could not complete OAuth token refresh.", + cause: undefined, + }); + expect(JSON.stringify(failure)).not.toContain(TOKEN_CANARY); + + const row = yield* Effect.promise(() => + config.db.findFirst("connection", { where: (b) => b("name", "=", "main") }), + ); + expect( + (row?.provider_state as { oauthReauthRequiredAt?: number } | null)?.oauthReauthRequiredAt, + ).toBeUndefined(); + expect( + JSON.stringify({ providerState: row?.provider_state, lastHealth: row?.last_health }), + ).not.toContain(TOKEN_CANARY); + + // An unknown classification is retryable and must not arm the known-dead gate. + recorder.grants.length = 0; + yield* Effect.flip(executor.execute(TOOL, {})); + expect(recorder.grants).toHaveLength(1); + }), + ), + ); + + it.effect("scrubs provider storage failures before they reach host error channels", () => + Effect.scoped( + Effect.gen(function* () { + const { config, executor, recorder } = yield* scenario({ + behaviour: { kind: "storageFailure" }, + }); + + const exit = yield* Effect.exit(executor.execute(TOOL, {})); + expectOpaqueProviderFailure( + exit, + "Credential provider could not complete OAuth token refresh.", + ); + + const row = yield* Effect.promise(() => + config.db.findFirst("connection", { where: (b) => b("name", "=", "main") }), + ); + expect( + JSON.stringify({ providerState: row?.provider_state, lastHealth: row?.last_health }), + ).not.toContain(TOKEN_CANARY); + expect( + (row?.provider_state as { oauthReauthRequiredAt?: number } | null)?.oauthReauthRequiredAt, + ).toBeUndefined(); + + // A storage failure is retryable and must not arm the known-dead grant gate. + recorder.grants.length = 0; + const retry = yield* Effect.exit(executor.execute(TOOL, {})); + expectOpaqueProviderFailure( + retry, + "Credential provider could not complete OAuth token refresh.", + ); + expect(recorder.grants).toHaveLength(1); + }), + ), + ); + + it.effect("scrubs a synchronous provider throw", () => + Effect.scoped( + Effect.gen(function* () { + const { executor } = yield* scenario({ behaviour: { kind: "syncThrow" } }); + const exit = yield* Effect.exit(executor.execute(TOOL, {})); + expectOpaqueProviderFailure( + exit, + "Credential provider could not complete OAuth token refresh.", + ); + }), + ), + ); + + it.effect("scrubs a provider defect", () => + Effect.scoped( + Effect.gen(function* () { + const { executor } = yield* scenario({ behaviour: { kind: "defect" } }); + const exit = yield* Effect.exit(executor.execute(TOOL, {})); + expectOpaqueProviderFailure( + exit, + "Credential provider could not complete OAuth token refresh.", + ); + }), + ), + ); + + it.effect("scrubs malformed rejection getters", () => + Effect.scoped( + Effect.gen(function* () { + const { executor } = yield* scenario({ + behaviour: { kind: "throwingRejectionGetter" }, + }); + const exit = yield* Effect.exit(executor.execute(TOOL, {})); + expectOpaqueProviderFailure( + exit, + "Credential provider could not complete OAuth token refresh.", + ); + }), + ), + ); + + it.effect("snapshots a stateful rejection classification exactly once", () => + Effect.scoped( + Effect.gen(function* () { + const { config, executor, recorder } = yield* scenario({ + behaviour: { kind: "changingRejectionGetter" }, + }); + const exit = yield* Effect.exit(executor.execute(TOOL, {})); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + expect(recorder.rejectionErrorReads).toBe(1); + expect(Cause.pretty(exit.cause)).toContain("invalid_grant"); + expect(Cause.pretty(exit.cause)).not.toContain(TOKEN_CANARY); + + const row = yield* Effect.promise(() => + config.db.findFirst("connection", { where: (b) => b("name", "=", "main") }), + ); + expect( + JSON.stringify({ providerState: row?.provider_state, lastHealth: row?.last_health }), + ).not.toContain(TOKEN_CANARY); + }), + ), + ); + + it.effect("scrubs a throwing refresh capability getter", () => + Effect.scoped( + Effect.gen(function* () { + const { executor } = yield* scenario({ + behaviour: { kind: "throwingCapabilityGetter" }, + }); + const exit = yield* Effect.exit(executor.execute(TOOL, {})); + expectOpaqueProviderFailure( + exit, + "Credential provider could not complete OAuth token refresh.", + ); + }), + ), + ); + + it.effect("scrubs a throwing expiry getter on a successful provider result", () => + Effect.scoped( + Effect.gen(function* () { + const { executor } = yield* scenario({ + behaviour: { kind: "throwingResultGetter", field: "expiry" }, + }); + const exit = yield* Effect.exit(executor.execute(TOOL, {})); + expectOpaqueProviderFailure( + exit, + "Credential provider could not complete OAuth token refresh.", + ); + }), + ), + ); + + it.effect("scrubs a throwing scope getter on a successful provider result", () => + Effect.scoped( + Effect.gen(function* () { + const { executor } = yield* scenario({ + behaviour: { kind: "throwingResultGetter", field: "scope" }, + }); + const exit = yield* Effect.exit(executor.execute(TOOL, {})); + expectOpaqueProviderFailure( + exit, + "Credential provider could not complete OAuth token refresh.", + ); + }), + ), + ); + + it.effect("refuses to persist a provider scope outside the host-trusted grant set", () => + Effect.scoped( + Effect.gen(function* () { + const { config, executor } = yield* scenario({ + behaviour: { + kind: "seals", + expiresInSeconds: 3_600, + scope: TOKEN_CANARY, + }, + }); + const exit = yield* Effect.exit(executor.execute(TOOL, {})); + expectOpaqueProviderFailure( + exit, + "Credential provider could not complete OAuth token refresh.", + ); + const row = yield* Effect.promise(() => + config.db.findFirst("connection", { where: (b) => b("name", "=", "main") }), + ); + expect( + JSON.stringify({ scope: row?.oauth_scope, lastHealth: row?.last_health }), + ).not.toContain(TOKEN_CANARY); + }), + ), + ); + + it.effect("preserves a provider method's receiver", () => + Effect.scoped( + Effect.gen(function* () { + const { executor } = yield* scenario({ behaviour: { kind: "requiresReceiver" } }); + expect(yield* executor.execute(TOOL, {})).toEqual({ token: "delegated-access-token" }); + }), + ), + ); + + it.effect("preserves cancellation while dropping a concurrent secret-bearing defect", () => + Effect.scoped( + Effect.gen(function* () { + const { executor } = yield* scenario({ behaviour: { kind: "interruptedDefect" } }); + const exit = yield* Effect.exit(executor.execute(TOOL, {})); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + expect(Cause.hasInterrupts(exit.cause)).toBe(true); + expect(exit.cause.reasons.every(Cause.isInterruptReason)).toBe(true); + expect(Cause.pretty(exit.cause)).not.toContain(TOKEN_CANARY); + }), + ), + ); + + it.effect("scrubs storage failures while resolving the refreshed access token", () => + Effect.scoped( + Effect.gen(function* () { + const { executor } = yield* scenario({ + behaviour: { kind: "readFailure", failure: "storage" }, + }); + const exit = yield* Effect.exit(executor.execute(TOOL, {})); + expectOpaqueProviderFailure( + exit, + "Credential provider could not resolve the refreshed access token.", + ); + }), + ), + ); + + it.effect("scrubs defects while resolving the refreshed access token", () => + Effect.scoped( + Effect.gen(function* () { + const { executor } = yield* scenario({ + behaviour: { kind: "readFailure", failure: "defect" }, + }); + const exit = yield* Effect.exit(executor.execute(TOOL, {})); + expectOpaqueProviderFailure( + exit, + "Credential provider could not resolve the refreshed access token.", + ); + }), + ), + ); + + it.effect("fails rather than reporting success when the new token cannot be read back", () => + Effect.scoped( + Effect.gen(function* () { + const { config, executor, recorder } = yield* scenario({ + behaviour: { kind: "sealsNothing" }, + }); + + const failure = yield* Effect.flip(executor.execute(TOOL, {})); + expect(failure).toBeInstanceOf(StorageError); + expect(failure.message).toBe( + "Credential provider did not make the refreshed access token resolvable.", + ); + expect(failure.cause).toBeUndefined(); + + // The row must NOT have been stamped with a fresh expiry — doing that over a token nobody + // can resolve leaves the connection reading healthy for a full lifetime while every call + // using it fails. + const row = yield* Effect.promise(() => + config.db.findFirst("connection", { where: (b) => b("name", "=", "main") }), + ); + expect(Number(row?.expires_at)).toBeLessThan(Date.now()); + expect( + (row?.provider_state as { oauthReauthRequiredAt?: number } | null)?.oauthReauthRequiredAt, + ).toBeUndefined(); + expect((row?.last_health as { status?: string } | null)?.status).not.toBe("expired"); + + // This is a provider invariant/storage failure, not a dead OAuth grant: retry it. + recorder.grants.length = 0; + yield* Effect.flip(executor.execute(TOOL, {})); + expect(recorder.grants).toHaveLength(1); + }), + ), + ); + + it.effect("refuses to delegate a grant to an endpoint the host's policy rejects", () => + Effect.scoped( + Effect.gen(function* () { + const { recorder, executor, config } = yield* scenario({ behaviour: SEALS }); + // The token URL is read from the connection row, so it is the caller's view of where the + // grant goes. Delegating the exchange must not delegate the guard: a provider holding a + // sealed refresh token would otherwise post it wherever this column pointed. + yield* Effect.promise(() => + config.db.updateMany("connection", { + where: (b) => b("name", "=", "main"), + set: { oauth_token_url: "http://evil.example/token" }, + }), + ); + + const failure = yield* Effect.flip(executor.execute(TOOL, {})); + expect(JSON.stringify(failure)).toContain("https:"); + // The point of the guard: the provider is never asked, so the sealed token never moves. + expect(recorder.grants).toHaveLength(0); + }), + ), + ); + + it.effect("leaves client_credentials on the host-side exchange", () => + Effect.scoped( + Effect.gen(function* () { + const { recorder, executor } = yield* scenario({ + behaviour: SEALS, + grant: "client_credentials", + }); + yield* executor.execute(TOOL, {}); + + // client_credentials has no refresh token to spend — the token is re-minted from the + // client id/secret — so it is a different exchange and must not be delegated. + expect(recorder.grants).toHaveLength(0); + }), + ), + ); + + it.effect("leaves an app whose secret is host config on the host-side exchange", () => + Effect.scoped( + Effect.gen(function* () { + const { recorder, server, executor } = yield* scenario({ + behaviour: SEALS, + firstParty: true, + }); + yield* executor.execute(TOOL, {}); + + // A config-declared app's secret is a literal in this process, not a provider item, so + // there is no id to name it by. Delegating anyway would hand the provider a grant it + // cannot authenticate, and the authorization server would refuse every refresh. + expect(recorder.grants).toHaveLength(0); + expect(yield* hostRefreshRequests(server)).toHaveLength(1); + }), + ), + ); + + it.effect("keeps refreshing a connection that has no recorded scope", () => + Effect.scoped( + Effect.gen(function* () { + const { config, executor } = yield* scenario({ behaviour: SEALS }); + // RFC 6749 §5.1 lets an authorization server omit the granted scope, so a live connection + // can legitimately carry none. The scope validation must not turn that into a permanent + // failure: with nothing recorded there is nothing to widen from. + yield* Effect.promise(() => + config.db.updateMany("connection", { + where: (b) => b("name", "=", "main"), + set: { oauth_scope: null }, + }), + ); + + const out = yield* executor.execute(TOOL, {}); + expect(out).toEqual({ token: "delegated-access-token" }); + + // The provider's scope string is still never persisted — that is the property the + // validation exists to hold, and it holds here by recording nothing at all. + const row = yield* Effect.promise(() => + config.db.findFirst("connection", { where: (b) => b("name", "=", "main") }), + ); + expect(row?.oauth_scope).toBeNull(); + }), + ), + ); +}); diff --git a/packages/core/sdk/src/promise.ts b/packages/core/sdk/src/promise.ts index d48d106071..775abd8d86 100644 --- a/packages/core/sdk/src/promise.ts +++ b/packages/core/sdk/src/promise.ts @@ -38,7 +38,18 @@ export type { // Credential providers are Effect-native (their `get`/`set` return `Effect`s), // but Promise consumers still author them to register an inline writable store // via `createExecutor({ providers })`. -export type { CredentialProvider, ProviderEntry } from "./provider"; +export { + MAX_REFRESH_GRANT_EXPIRES_IN_SECONDS, + RefreshGrantRejected, + isRefreshGrantRejectionCode, +} from "./provider"; +export type { + CredentialProvider, + ProviderEntry, + RefreshGrantInput, + RefreshGrantRejectionCode, + RefreshGrantResult, +} from "./provider"; export type { CreateToolPolicyInput, RemoveToolPolicyInput, diff --git a/packages/core/sdk/src/provider.ts b/packages/core/sdk/src/provider.ts index 631d705e7f..f4eaeb89f4 100644 --- a/packages/core/sdk/src/provider.ts +++ b/packages/core/sdk/src/provider.ts @@ -1,7 +1,8 @@ -import type { Effect } from "effect"; +import { Data, type Effect } from "effect"; import type { StorageFailure } from "./fuma-runtime"; import type { ProviderItemId, ProviderKey } from "./ids"; +import type { TokenEndpointAuthMethod } from "./oauth-client"; /* Where a credential's value actually lives — the v2 successor to v1's * `SecretProvider`. The default store holds pasted values; external backends @@ -39,4 +40,134 @@ export interface CredentialProvider { /** Browse entries for discovery (pick a 1Password item). Optional — some * backends can't enumerate. */ readonly list?: () => Effect.Effect; + /** Perform the OAuth refresh grant inside the provider, instead of handing the + * refresh token out to be exchanged here. + * + * A provider that serves an indirection can protect an access token: it is + * spent against a bound host, and the reply is not itself a credential. The + * refresh grant breaks that — the exchange needs the real refresh token and + * the reply carries a brand-new one — so a store the host genuinely cannot + * read has to refuse the refresh item, losing refresh entirely. Implementing + * this gives it the other option: own the exchange, seal the new tokens under + * the same item ids, and report only what the caller's bookkeeping needs. + * + * OPTIONAL — when absent the caller performs the exchange itself, unchanged. + * Implement it only if the exchange genuinely happens somewhere the host + * cannot read; returning success without performing the grant is worse than + * not implementing it. */ + readonly refreshGrant?: ( + input: RefreshGrantInput, + ) => Effect.Effect; } + +/** What the provider needs to perform the grant on the caller's behalf. + * + * Secrets are named by ITEM ID, never passed as values — passing the refresh + * token or the client secret here would reintroduce exactly the exposure this + * interface exists to remove. + * + * SECURITY: this ENTIRE input tuple is the CALLER's view. A caller whose + * process is part of the threat model can rewrite not only `tokenUrl`, but + * also every item id, the client id/auth method, scopes, and resource. A + * provider that withholds credentials from that caller MUST authenticate the + * complete tuple against independently trusted enrollment metadata and reject + * mismatches before resolving or spending any secret. This warning documents + * the current contract; it does not solve the structural limitation that the + * API still transports caller-authored grant parameters rather than one + * provider-owned sealed descriptor. */ +export interface RefreshGrantInput { + /** The stored refresh token to spend. */ + readonly refreshItemId: ProviderItemId; + /** Where to seal the newly minted access token. The caller reads it back from + * here through `get`. */ + readonly accessItemId: ProviderItemId; + /** The OAuth app's client secret, by id. Absent for a public client. */ + readonly clientSecretItemId?: ProviderItemId; + /** The token endpoint to post to. A mismatch against the provider's enrolled + * endpoint can exfiltrate the sealed refresh token. */ + readonly tokenUrl: string; + readonly clientId: string; + /** How to present the client secret, as recorded on the OAuth app: `"body"` + * is `client_secret_post`, `"basic"` is `client_secret_basic`, and + * `"basic_raw"` is HTTP Basic without form-encoding the two halves, for + * authorization servers that reject the encoded form. Passed explicitly so a + * provider never has to guess — RFC 6749 §2.3.1 prefers Basic while this + * caller's default is post, so a guess would be wrong as often as right. */ + readonly clientAuth: TokenEndpointAuthMethod; + /** How to encode the request body: `"form"` is the RFC 6749 §4.1.3 + * `application/x-www-form-urlencoded` default, `"json"` is for the + * authorization servers that accept only JSON. Absent means `"form"`. */ + readonly requestFormat?: "form" | "json"; + readonly scopes: readonly string[]; + /** RFC 8707 — keeps the re-minted token bound to the same resource. */ + readonly resource?: string; +} + +/** Deliberately carries NO token material. + * + * These two fields are the whole of what the caller needs to update a + * connection row after a refresh; anything more would put the host back in the + * data path. A rotated refresh token is sealed by the provider under the same + * `refreshItemId` and is never reported here. */ +export interface RefreshGrantResult { + /** Lifetime in seconds (RFC 6749 §5.1 `expires_in`), or null when the + * authorization server did not say. + * + * RELATIVE, not an absolute instant, precisely because the provider may run + * where the caller cannot read — which usually means a different machine and + * therefore a different clock. The caller converts against its OWN clock, the + * same one that later decides whether the token is due for refresh. Executor + * accepts only a finite, non-negative value no greater than + * `MAX_REFRESH_GRANT_EXPIRES_IN_SECONDS`. */ + readonly expiresInSeconds: number | null; + /** The granted scope as reported by the authorization server, or null when it + * did not report one (distinct from an empty scope). Executor accepts only a + * canonical subset of the connection's already-recorded granted scopes. */ + readonly scope: string | null; +} + +/** Largest delegated access-token lifetime Executor accepts: ten 365-day years. */ +export const MAX_REFRESH_GRANT_EXPIRES_IN_SECONDS = 10 * 365 * 24 * 60 * 60; + +/** The closed standards-defined token-endpoint error set. + * + * Keeping this classification closed is a custody boundary: a provider error + * reaches host telemetry and, for `invalid_grant`, persisted connection health. + * Free-form values would therefore be another channel for token material. The + * first six values are RFC 6749 section 5.2; `invalid_target` is RFC 8707 + * section 4 for this API's optional `resource` parameter. */ +const REFRESH_GRANT_REJECTION_CODES = [ + "invalid_request", + "invalid_client", + "invalid_grant", + "unauthorized_client", + "unsupported_grant_type", + "invalid_scope", + "invalid_target", +] as const; + +export type RefreshGrantRejectionCode = (typeof REFRESH_GRANT_REJECTION_CODES)[number]; + +export const isRefreshGrantRejectionCode = (value: unknown): value is RefreshGrantRejectionCode => + typeof value === "string" && (REFRESH_GRANT_REJECTION_CODES as readonly string[]).includes(value); + +/** The authorization server refused the grant. + * + * Distinct from `StorageFailure` because the two demand opposite responses: a + * storage failure is transient and worth retrying, whereas a standards-defined + * token-endpoint refusal is the AS's standing verdict — `invalid_grant` in + * particular means the refresh token is dead and only re-authentication + * recovers it. Without this the caller cannot tell "the vault is down" from + * "this connection is finished", so it can neither prompt for re-auth nor stop + * re-sending a grant that will never succeed. + * + * Only the closed standards-defined classification crosses this boundary. In + * particular there is deliberately no provider-controlled message or cause: + * those fields can contain response bodies, URLs, or secret-bearing errors and + * would be surfaced to callers, persistence, or logs by the host. */ +export class RefreshGrantRejected extends Data.TaggedError("RefreshGrantRejected")<{ + /** The validated token-endpoint code (`invalid_grant`, `invalid_client`, + * `invalid_target`, …) when the endpoint returned one. Omit it for a failure + * that carried no code — the caller then treats the failure as transient. */ + readonly error?: RefreshGrantRejectionCode; +}> {} diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index 391c12e3fa..fde8d13dbd 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -46,7 +46,18 @@ export type { UpdateConnectionInput, ValidateConnectionInput, } from "./connection"; -export type { CredentialProvider, ProviderEntry } from "./provider"; +export { + MAX_REFRESH_GRANT_EXPIRES_IN_SECONDS, + RefreshGrantRejected, + isRefreshGrantRejectionCode, +} from "./provider"; +export type { + CredentialProvider, + ProviderEntry, + RefreshGrantInput, + RefreshGrantRejectionCode, + RefreshGrantResult, +} from "./provider"; export type { Tool, ToolDef, ToolListFilter, ToolAnnotations } from "./tool"; // Tagged errors (Schema-based — browser-safe).