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).