Skip to content

feat(sdk): let a credential provider perform the OAuth refresh grant itself - #2084

Open
GeiserX wants to merge 1 commit into
UsefulSoftwareCo:mainfrom
GeiserX:fix/provider-owned-oauth-refresh
Open

GeiserX wants to merge 1 commit into
UsefulSoftwareCo:mainfrom
GeiserX:fix/provider-owned-oauth-refresh

Conversation

@GeiserX

@GeiserX GeiserX commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Executor performs the OAuth refresh exchange itself, which means it first asks the credential
provider to hand over the stored refresh token and the app's client secret. A provider whose whole
purpose is to keep those values sealed cannot answer that, so it has to refuse the refresh item and
lose refresh entirely. The refresh grant is the one exchange where a long-lived stored secret must
be spent and where the reply is itself a fresh credential, so it is exactly the place a sealed
store has no way through.

CredentialProvider gains an optional refreshGrant. When a provider implements it, Executor asks
it to perform the exchange rather than to reveal anything: the provider spends the refresh token,
seals the newly minted access token and any rotated refresh token under the same item ids, and
returns only the granted lifetime and scope. Secrets cross the seam as item ids, never as values,
so the host resolves neither the refresh token nor the client secret on that path. Executor
re-validates the returned metadata before it persists anything, rebuilding scope from the grant set
it already trusts and converting the lifetime against its own clock, then reads the access token
back through get like every other credential. A refusal comes back as a closed set of
standards-defined token-endpoint codes, so a provider refusal classifies re-authentication and arms
the known-dead grant gate exactly as a host-side refusal does. Providers that do not implement
refreshGrant are unaffected, and so are the grants and the apps that have nothing to delegate.

This narrows what the host holds; it does not empty it. The access token still passes through the
host at the moment it is spent, because that is what invoking a tool with it requires. What the
change removes from the host's data path is the long-lived material: the refresh token that can
mint indefinitely, and the client secret that authenticates the app. The grant parameters are also
still authored by the caller, so a provider that withholds credentials from that caller has to
authenticate the whole tuple against its own enrollment metadata before it opens anything.

Tests cover the delegated path end to end against a real test authorization server, pinning that
the host never resolves the refresh token or the client secret and never posts a refresh grant
itself, that the host path is unchanged without the capability, that each rejection code classifies
like its host-side equivalent, that malformed and hostile provider results stay inside the provider
boundary, and that a connection with no recorded scope keeps refreshing.

This is part of the credential-handling series described in #1585, which lists every change and the reasoning behind it.

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant