Add an on-demand OAuth refresh for connections - #2152
Open
ramarivera wants to merge 1 commit into
Open
ramarivera wants to merge 1 commit into
ramarivera wants to merge 1 commit into
Conversation
OAuth access tokens are refreshed lazily: when a token is within the expiry skew, or after an upstream 401. A connection nobody uses never spends its refresh token, so providers that expire idle refresh tokens kill it and the user has to reconnect in a browser. Nothing lets an operator run the grant ahead of time. Add connections.refreshOAuthToken(ref) and POST /connections/:owner/:integration/:name/oauth/refresh. The grant runs through refreshConnectionToken, so it joins an in-flight refresh of the same connection and persists a rotated refresh token before the access token, like the lazy paths. It returns the new expiry and a health verdict. A refused grant is reported as a verdict (a dead grant reads expired and is not re-sent); a connection with no OAuth grant is InvalidConnectionInputError (400). The org-write guard matches connections.refresh.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This adds
connections.refreshOAuthToken(ref)andPOST /connections/:owner/:integration/:name/oauth/refresh, which run a connection's OAuth refresh grant now, even when the access token isn't due yet. An operator can call it on a schedule so an idle connection keeps spending its refresh token, instead of letting a provider's inactivity window kill the grant withinvalid_grant.Linked issue
Fixes #2151
What it does
refreshConnectionToken, the same in-flight gate the proactive and reactive refreshes use. A forced refresh that overlaps a tool call's refresh joins that grant rather than spending the single-use refresh token a second time. Persistence is unchanged:persistRefreshedTokenstores a rotated refresh token before the access token. The span recordsexecutor.oauth.refresh.trigger: "manual".{ refreshed, expiresAt, health }. On success it runscheckHealthonce, sohealthis a probed verdict that gets persisted the same way. A minted token only shows the authorization server still honours the grant, not that the upstream accepts the token.checkHealth. A dead grant (already recorded, or rejected now) returnsrefreshed: falsewithhealth.status: "expired". A recorded dead grant is not sent to the token endpoint again. Other refusals come backdegraded. Transient failures (5xx, network) stay a 500, the same as the other connection routes.InvalidConnectionInputError(400). An unknown connection fails withConnectionNotFoundError(404).guardOrgWrite(ref.owner)is applied, matchingconnections.refresh: members without workspace writes can refresh their own connections but not workspace ones (403). Automatic refreshes during tool calls are unaffected.Design notes
/refreshor/health./refreshre-syncs the tool catalog and returns tools. Overloading it with a token grant would mix two unrelated side effects behind one name./healthis read-shaped and is called on every page mount withifStaleMs, so a flag there would put a token-spending operation behind the most frequently called route. Puttingoauth/under the connection path makes the scope clear.forceRefreshConnectionValuesreturnsnullfor connections without a stored refresh token. This path callsrefreshConnectionTokendirectly, so client-credentials and enterprise-managed connections go through the same grant branches as the lazy path, and a missing refresh token surfaces as acredential_missingverdict instead of being skipped silently. That follows from the code path. This PR doesn't add dedicated tests for those grant types.offline_access).Verification
bun run format:check: all files formattedbun run lint: 0 warnings, 0 errorsbun run typecheck: 45/45 turbo tasks passedbun run test: 39/39 turbo tasks passed (4191 tests passed, 0 failed, 29 skipped)scenarios/connection-oauth-force-refresh.test.ts("Connections · an OAuth refresh can be forced before the access token is due") passed onselfhostandcloud(vitest run --project <target> scenarios/connection-oauth-force-refresh.test.ts). It's API-only, so there is no browser recording. The run manifests list the scenariook: truewith no skips.New tests:
packages/core/sdk/src/oauth-flow.test.ts(connections.refreshOAuthToken):performTokenRefreshdirectly), this test fails withinvalid_grant.expired/credential_refresh_rejected, is recorded, and is not re-sent.packages/core/sdk/src/org-writes.test.ts: a member is refused on a workspace connection and allowed through the guard on a personal one.packages/core/api/src/connections/oauth-refresh.test.ts: the route returns 200 with the refreshed shape, 400 for non-OAuth, 404 for unknown, and 403 for a member on a workspace connection with no token request sent.Checklist
.changeset/connection-oauth-force-refresh.md).