Skip to content

Commit c69cc52

Browse files
committed
Contain the provider boundary on the delegated refresh grant
A credential provider is an external plugin boundary, so nothing it authors may reach host error channels, telemetry, or persisted connection state — those values can carry token responses and other secret material. - Close the rejection classification to the standards-defined set (RFC 6749 §5.2 plus RFC 8707 `invalid_target`) and validate it at runtime, so an unrecognised value cannot reach `oauthErrorCode`, span attributes or persisted health. Drop `message`/`cause` from `RefreshGrantRejected` entirely; Executor now emits fixed host-facing text carrying only the validated code. - Contain provider storage failures, synchronous throws, Effect defects, and throwing or stateful property getters — including on the capability itself, on the success object's fields, and on the post-grant read-back. Cancellation is still propagated as cancellation; only the provider-authored reasons are dropped. - Rebuild the persisted scope from the host's own recorded grant set rather than the provider's string. `oauth_scope` is replayed to the authorization server on the next refresh, so accepting it verbatim was a persisted provider-controlled channel. A scope outside the granted set fails the refresh (RFC 6749 §6: a refresh may narrow scope, never widen it). - Bound the reported lifetime to finite, non-negative and at most ten years, instead of stamping NaN/Infinity/negative straight into `expires_at`. - Treat an unresolvable read-back as a retryable provider-invariant failure rather than demanding re-authentication: the authorization server ACCEPTED the grant, so re-auth is the one remedy that cannot be required, and a rotated refresh token may already be sealed. - Re-export the contract from the promise and shared surfaces too, and generalise the security note from `tokenUrl` to the whole caller-authored input tuple.
1 parent d358caa commit c69cc52

7 files changed

Lines changed: 763 additions & 74 deletions

File tree

‎.changeset/provider-owned-oauth-refresh-grant.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@
66

77
`CredentialProvider` gains an optional `refreshGrant`. When a provider implements it, the host asks it to _perform_ the refresh exchange rather than to hand over 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 returns only the granted lifetime and scope. The host then resolves the access token through `get`, the same hop every other credential takes.
88

9-
This closes the one gap where a backend that keeps secrets sealed had no honest option but to refuse to refresh at all — the refresh grant is the only exchange where a long-lived stored secret must be spent and the reply is itself a fresh credential. Providers that do not implement `refreshGrant` are unaffected: the existing host-side exchange runs unchanged.
9+
This gives a backend that keeps secrets sealed a way to close the refresh gap instead of refusing refresh entirely — the refresh grant is the exchange where a long-lived stored secret must be spent and the reply is itself a fresh credential. The provider remains responsible for authenticating the complete caller-supplied grant tuple against independently trusted enrollment metadata before opening a secret. Providers that do not implement `refreshGrant` are unaffected: the existing host-side exchange runs unchanged.
1010

11-
A refused grant is reported with the new `RefreshGrantRejected` error carrying the RFC 6749 §5.2 code, so a delegated refresh classifies re-authentication, surfaces `invalid_grant` to the caller, and arms the known-dead gate exactly as the host-side path does.
11+
A refused grant is reported with the new `RefreshGrantRejected` error carrying a closed standards-defined token-endpoint code (RFC 6749 §5.2 plus RFC 8707 `invalid_target`), so a delegated refresh classifies re-authentication, surfaces `invalid_grant` to the caller, and arms the known-dead gate exactly as the host-side path does. Free-form provider messages, causes, defects, and malformed result metadata stay inside the provider boundary; Executor generates fixed host-facing text and only persists validated lifetime/scope metadata.

‎packages/core/sdk/src/executor.ts‎

Lines changed: 144 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Effect, Inspectable, Layer, Option, Predicate, Schema } from "effect";
1+
import { Cause, Effect, Inspectable, Layer, Option, Predicate, Schema } from "effect";
22
import { FetchHttpClient, type HttpClient } from "effect/unstable/http";
33
import { fumadb } from "@executor-js/fumadb";
44
import { memoryAdapter } from "@executor-js/fumadb/adapters/memory";
@@ -122,7 +122,13 @@ import {
122122
type ToolPolicy,
123123
type UpdateToolPolicyInput,
124124
} from "./policies";
125-
import type { CredentialProvider, ProviderEntry } from "./provider";
125+
import {
126+
MAX_REFRESH_GRANT_EXPIRES_IN_SECONDS,
127+
isRefreshGrantRejectionCode,
128+
type CredentialProvider,
129+
type ProviderEntry,
130+
type RefreshGrantRejected,
131+
} from "./provider";
126132
import { touchSubject } from "./subject-registry";
127133
import type {
128134
AnyPlugin,
@@ -1898,7 +1904,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
18981904
// non-invalid_grant 400 and callers saw only the opaque defect).
18991905
// Code-less failures (transport blips, non-OAuth-shaped responses) stay
19001906
// StorageError so the next invoke retries.
1901-
const classifyGrantRefusal = (cause: {
1907+
const classifyHostGrantRefusal = (cause: {
19021908
readonly message: string;
19031909
readonly error?: string;
19041910
}): CredentialResolutionError | StorageError =>
@@ -1918,6 +1924,31 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
19181924
cause,
19191925
});
19201926

1927+
// A provider is a credential boundary, so never project its Error
1928+
// message/cause (or an unrecognised `error` value) into host errors.
1929+
// Those values may contain token responses or other secret material.
1930+
// The closed standards-defined code is the only provider-controlled value allowed to
1931+
// reach callers, health persistence, or span attributes.
1932+
const classifyProviderGrantRefusal = (
1933+
cause: RefreshGrantRejected,
1934+
): CredentialResolutionError | StorageError => {
1935+
const reportedError = cause.error;
1936+
const error = isRefreshGrantRejectionCode(reportedError) ? reportedError : undefined;
1937+
return error !== undefined
1938+
? new CredentialResolutionError({
1939+
owner,
1940+
integration: IntegrationSlug.make(row.integration),
1941+
name: ConnectionName.make(row.name),
1942+
message: `OAuth token refresh was rejected (${error}).`,
1943+
reauthRequired: error === "invalid_grant",
1944+
oauthErrorCode: error,
1945+
})
1946+
: new StorageError({
1947+
message: "Credential provider could not complete OAuth token refresh.",
1948+
cause: undefined,
1949+
});
1950+
};
1951+
19211952
// Persist the definitive verdict so the NEXT refresh skips the doomed
19221953
// grant (see the known-dead gate above) and the connection shows
19231954
// `expired` without waiting for a probe. Shared for the same reason as
@@ -1941,7 +1972,26 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
19411972
// client_credentials is excluded deliberately: it has no refresh token
19421973
// to spend (the token is re-minted from the client id/secret), so it is
19431974
// a different exchange and is left on the path below.
1944-
if (provider.refreshGrant && String(clientRow.grant) !== "client_credentials") {
1975+
const providerRefreshFailure = () =>
1976+
new StorageError({
1977+
message: "Credential provider could not complete OAuth token refresh.",
1978+
cause: undefined,
1979+
});
1980+
const preserveProviderInterruption = <E>(
1981+
cause: Cause.Cause<E>,
1982+
): Effect.Effect<never, never> =>
1983+
Effect.failCause(Cause.fromReasons<never>(cause.reasons.filter(Cause.isInterruptReason)));
1984+
const delegatedRefreshGrant =
1985+
String(clientRow.grant) === "client_credentials"
1986+
? undefined
1987+
: yield* Effect.suspend(() => Effect.succeed(provider.refreshGrant)).pipe(
1988+
Effect.catchCause((cause) =>
1989+
Cause.hasInterrupts(cause)
1990+
? preserveProviderInterruption(cause)
1991+
: Effect.fail(providerRefreshFailure()),
1992+
),
1993+
);
1994+
if (delegatedRefreshGrant && String(clientRow.grant) !== "client_credentials") {
19451995
if (!row.refresh_item_id) {
19461996
return yield* reauth("No refresh token is stored for this connection.");
19471997
}
@@ -1953,8 +2003,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
19532003
`OAuth token URL "${tokenUrl}" must use https: or loopback http:.`,
19542004
);
19552005
}
1956-
const granted = yield* provider
1957-
.refreshGrant({
2006+
const granted = yield* Effect.suspend(() =>
2007+
delegatedRefreshGrant.call(provider, {
19582008
refreshItemId: ProviderItemId.make(String(row.refresh_item_id)),
19592009
accessItemId: tokenItemId,
19602010
clientSecretItemId: clientRow.client_secret_item_id
@@ -1968,21 +2018,95 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
19682018
scopes: grantedScopes,
19692019
// RFC 8707: keep the re-minted token bound to the same resource.
19702020
resource: clientRow.resource ? String(clientRow.resource) : undefined,
1971-
})
1972-
.pipe(
1973-
Effect.catchTag("RefreshGrantRejected", (cause) =>
1974-
Effect.fail(classifyGrantRefusal(cause)),
1975-
),
1976-
Effect.tapError(armKnownDeadGate),
1977-
);
2021+
}),
2022+
).pipe(
2023+
// Project the success value while it is still inside the guarded provider boundary.
2024+
// Accessors on a remote/plugin object can throw, and an arbitrary scope string would
2025+
// otherwise be a direct channel into persisted host state. Rebuild scope exclusively
2026+
// from the host's already-trusted grant set.
2027+
Effect.flatMap((result) =>
2028+
Effect.suspend(() => {
2029+
const expiresInSeconds = result.expiresInSeconds;
2030+
const reportedScope = result.scope;
2031+
if (
2032+
expiresInSeconds !== null &&
2033+
(typeof expiresInSeconds !== "number" ||
2034+
!Number.isFinite(expiresInSeconds) ||
2035+
expiresInSeconds < 0 ||
2036+
expiresInSeconds > MAX_REFRESH_GRANT_EXPIRES_IN_SECONDS)
2037+
) {
2038+
return Effect.fail(providerRefreshFailure());
2039+
}
2040+
if (reportedScope !== null && typeof reportedScope !== "string") {
2041+
return Effect.fail(providerRefreshFailure());
2042+
}
2043+
const trustedScopes = new Map(grantedScopes.map((scope) => [scope, scope]));
2044+
const reportedScopes =
2045+
reportedScope === null
2046+
? null
2047+
: [...new Set(reportedScope.split(/\s+/).filter(Boolean))];
2048+
if (
2049+
reportedScopes !== null &&
2050+
reportedScopes.some((scope) => !trustedScopes.has(scope))
2051+
) {
2052+
return Effect.fail(providerRefreshFailure());
2053+
}
2054+
return Effect.succeed({
2055+
expiresInSeconds,
2056+
scope:
2057+
reportedScopes === null
2058+
? null
2059+
: reportedScopes.map((scope) => trustedScopes.get(scope)!).join(" "),
2060+
});
2061+
}),
2062+
),
2063+
// This is an external plugin boundary. Preserve cancellation, but discard every
2064+
// provider-authored failure/defect before it can reach Cause.pretty, traces, or logs.
2065+
Effect.catchCause((cause) => {
2066+
if (Cause.hasInterrupts(cause)) return preserveProviderInterruption(cause);
2067+
const reason = cause.reasons.length === 1 ? cause.reasons[0] : undefined;
2068+
return Effect.suspend(() =>
2069+
Effect.succeed(
2070+
reason !== undefined &&
2071+
Cause.isFailReason(reason) &&
2072+
Predicate.isTagged(reason.error, "RefreshGrantRejected")
2073+
? classifyProviderGrantRefusal(reason.error)
2074+
: providerRefreshFailure(),
2075+
),
2076+
).pipe(
2077+
// Even a malformed tagged object may throw from `_tag`/`error` accessors.
2078+
Effect.catchCause((classificationCause) =>
2079+
Cause.hasInterrupts(classificationCause)
2080+
? preserveProviderInterruption(classificationCause)
2081+
: Effect.succeed(providerRefreshFailure()),
2082+
),
2083+
Effect.flatMap((error) => Effect.fail(error)),
2084+
);
2085+
}),
2086+
Effect.tapError(armKnownDeadGate),
2087+
);
19782088
// Read the token back BEFORE recording success. A provider that
19792089
// reported a grant it did not actually seal would otherwise leave the
19802090
// row stamped with a fresh expiry over a stale or absent token, and
19812091
// the connection would read healthy for a whole token lifetime while
19822092
// every call using it failed.
1983-
const access = yield* provider.get(tokenItemId);
1984-
if (!access) {
1985-
return yield* reauth("Refreshed access token could not be resolved.");
2093+
const access = yield* Effect.suspend(() => provider.get(tokenItemId)).pipe(
2094+
Effect.catchCause((cause) =>
2095+
Cause.hasInterrupts(cause)
2096+
? preserveProviderInterruption(cause)
2097+
: Effect.fail(
2098+
new StorageError({
2099+
message: "Credential provider could not resolve the refreshed access token.",
2100+
cause: undefined,
2101+
}),
2102+
),
2103+
),
2104+
);
2105+
if (typeof access !== "string" || access.length === 0) {
2106+
return yield* new StorageError({
2107+
message: "Credential provider did not make the refreshed access token resolvable.",
2108+
cause: undefined,
2109+
});
19862110
}
19872111
// Convert on OUR clock, never the provider's — `shouldRefreshToken`
19882112
// compares the stored instant against this same clock, so an absolute
@@ -2051,7 +2175,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
20512175
resource: clientRow.resource ? String(clientRow.resource) : undefined,
20522176
endpointUrlPolicy: config.oauthEndpointUrlPolicy,
20532177
fetch: config.fetch,
2054-
}).pipe(Effect.mapError(classifyGrantRefusal), Effect.tapError(armKnownDeadGate));
2178+
}).pipe(
2179+
Effect.mapError(classifyHostGrantRefusal),
2180+
Effect.tapError(armKnownDeadGate),
2181+
);
20552182
});
20562183

20572184
if (provider.set) {

‎packages/core/sdk/src/index.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,16 @@ export type {
102102
export type { Tool, ToolDef, ToolListFilter, ToolAnnotations } from "./tool";
103103

104104
// Credential providers.
105-
export { RefreshGrantRejected } from "./provider";
105+
export {
106+
MAX_REFRESH_GRANT_EXPIRES_IN_SECONDS,
107+
RefreshGrantRejected,
108+
isRefreshGrantRejectionCode,
109+
} from "./provider";
106110
export type {
107111
CredentialProvider,
108112
ProviderEntry,
109113
RefreshGrantInput,
114+
RefreshGrantRejectionCode,
110115
RefreshGrantResult,
111116
} from "./provider";
112117

0 commit comments

Comments
 (0)