Skip to content

Fork-only CI: PR #1564 (provider-owned OAuth refresh grant) - #3

Open
GeiserX wants to merge 6 commits into
mainfrom
fork-ci/oauth-refresh-grant
Open

Fork-only CI: PR #1564 (provider-owned OAuth refresh grant)#3
GeiserX wants to merge 6 commits into
mainfrom
fork-ci/oauth-refresh-grant

Conversation

@GeiserX

@GeiserX GeiserX commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Fork-internal, never for upstream. Runs the repo's own gates against UsefulSoftwareCo#1564's content on free runners, because upstream's ci.yml targets blacksmith runners this fork does not have — which is why UsefulSoftwareCo#1564 has never carried a check.

Content is upstream-pr/oauth-refresh-grant plus the fork-only workflow. Every other workflow in this fork is disabled, so nothing can deploy or publish.

A provider that serves an indirection instead of a raw value can protect an
access token: that token's only use is to be sent to 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 authorization server's reply carries a
brand-new real access token, so serving an indirection here moves the exposure
one step later while appearing to remove it. Providers backed by a sealed store
have to refuse the refresh item outright today, which costs them refresh
entirely.

Add an optional `refreshGrant` to CredentialProvider so such a provider can own
the exchange instead: it spends the refresh token, seals the new access token
(and a rotated refresh token) under the same item ids, and returns only
`{ expiresAt, scope }`. The caller then reads the access token back through
`get`, the same hop every other credential already takes.

Absence is not a downgrade: when the method is missing the existing host-side
exchange runs unchanged. client_credentials is excluded deliberately - it has
no refresh token to spend. Secrets are named by item id, never passed as
values, since passing them would reintroduce the exposure this removes.

The test pins the custody property directly - that the host never resolves the
refresh token through the provider - rather than asserting the refresh
succeeded, because a provider that quietly served the token would also go
green.
Address review of the delegated refresh path: the fast path returned before
the machinery that classifies an authorization-server refusal, so a provider
that owned the grant lost re-auth entirely.

- Report a refusal with a typed `RefreshGrantRejected` carrying the RFC 6749
  §5.2 code. Both grant paths now share one classifier and one known-dead
  gate, so a delegated refresh surfaces `invalid_grant` to the caller and
  stops re-sending a doomed grant on every proactive cycle.
- Resolve the client secret BELOW the delegated branch. It was read in
  plaintext and then never used, which both defeated the point of passing
  `clientSecretItemId` and meant a store that seals that item failed the
  refresh before `refreshGrant` was ever reached.
- Read the new access token back before recording success, and fail when it
  cannot be resolved, instead of stamping a fresh expiry over a token nobody
  can read.
- Enforce the host's OAuth endpoint URL policy on the delegated path too.
- Report `expiresInSeconds` rather than an absolute instant, so the caller
  converts on the clock that later decides whether the token is due.
- Carry `clientAuth` so a provider never has to guess the client
  authentication method.
- Export `RefreshGrantInput`, `RefreshGrantResult` and `RefreshGrantRejected`
  from the package entry point; an external provider could not name them.
- Add a changeset, and cover the refusal, read-back, scope, expiry and
  client_credentials paths.
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.
The scope validation compared the provider's reported scope against the
connection's recorded grant set. When that set is empty the comparison had no
subset to accept, so every reported scope failed the whole refresh — and the
failure is a retryable StorageError, so the connection would retry a grant that
could never succeed, indefinitely.

An empty grant set is a legitimate state, not a corrupt one: RFC 6749 §5.1
lets an authorization server omit the scope it granted, and the refresh request
then omits the scope parameter entirely. With nothing recorded there is also
nothing to widen away from, so the safe action is to keep the refresh and
record no scope. The provider's string is still never persisted, which is the
property the validation exists to hold.
Fork-internal. Upstream's ci.yml targets blacksmith runners this fork does not
have, so its jobs queue with no runner and UsefulSoftwareCo#1564 has never carried a check.
This runs lint, format, typecheck and the full core/sdk suite - including the
909-line delegation test this PR adds - on ubuntu-latest, which is free and
uncapped for public repositories.

FORK-ONLY. Must never reach the upstream pull request.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@GeiserX, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 41 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ee321982-934a-4a5c-a84e-834f7b5d189c

📥 Commits

Reviewing files that changed from the base of the PR and between e981528 and 5d2c0a1.

📒 Files selected for processing (8)
  • .changeset/provider-owned-oauth-refresh-grant.md
  • .github/workflows/fork-gates.yml
  • packages/core/sdk/src/executor.ts
  • packages/core/sdk/src/index.ts
  • packages/core/sdk/src/oauth-refresh-grant-delegation.test.ts
  • packages/core/sdk/src/promise.ts
  • packages/core/sdk/src/provider.ts
  • packages/core/sdk/src/shared.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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