Skip to content

Add an on-demand OAuth refresh for connections - #2152

Open
ramarivera wants to merge 1 commit into
UsefulSoftwareCo:mainfrom
ramarivera:feat/connection-oauth-force-refresh
Open

ramarivera wants to merge 1 commit into
UsefulSoftwareCo:mainfrom
ramarivera:feat/connection-oauth-force-refresh

Conversation

@ramarivera

Copy link
Copy Markdown
Contributor

Summary

This adds connections.refreshOAuthToken(ref) and POST /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 with invalid_grant.

Linked issue

Fixes #2151

What it does

  • Reuses the existing refresh path. The grant goes through 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: persistRefreshedToken stores a rotated refresh token before the access token. The span records executor.oauth.refresh.trigger: "manual".
  • Response: { refreshed, expiresAt, health }. On success it runs checkHealth once, so health is 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.
  • Failures are verdicts, as in checkHealth. A dead grant (already recorded, or rejected now) returns refreshed: false with health.status: "expired". A recorded dead grant is not sent to the token endpoint again. Other refusals come back degraded. Transient failures (5xx, network) stay a 500, the same as the other connection routes.
  • Errors. A connection with no OAuth grant fails with InvalidConnectionInputError (400). An unknown connection fails with ConnectionNotFoundError (404).
  • Guard. guardOrgWrite(ref.owner) is applied, matching connections.refresh: members without workspace writes can refresh their own connections but not workspace ones (403). Automatic refreshes during tool calls are unaffected.

Design notes

  • A new route, not a flag on /refresh or /health. /refresh re-syncs the tool catalog and returns tools. Overloading it with a token grant would mix two unrelated side effects behind one name. /health is read-shaped and is called on every page mount with ifStaleMs, so a flag there would put a token-spending operation behind the most frequently called route. Putting oauth/ under the connection path makes the scope clear.
  • No second refresh implementation. forceRefreshConnectionValues returns null for connections without a stored refresh token. This path calls refreshConnectionToken directly, so client-credentials and enterprise-managed connections go through the same grant branches as the lazy path, and a missing refresh token surfaces as a credential_missing verdict instead of being skipped silently. That follows from the code path. This PR doesn't add dedicated tests for those grant types.
  • Out of scope: a built-in scheduler, and any change to requested scopes (for example offline_access).

Verification

  • bun run format:check: all files formatted
  • bun run lint: 0 warnings, 0 errors
  • bun run typecheck: 45/45 turbo tasks passed
  • bun run test: 39/39 turbo tasks passed (4191 tests passed, 0 failed, 29 skipped)
  • e2e: scenarios/connection-oauth-force-refresh.test.ts ("Connections · an OAuth refresh can be forced before the access token is due") passed on selfhost and cloud (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 scenario ok: true with no skips.

New tests:

  • packages/core/sdk/src/oauth-flow.test.ts (connections.refreshOAuthToken):
    • A token with an hour left is refreshed. Tool calls then use the new access token, and a second forced refresh succeeds against an authorization server that forgets spent refresh tokens, which shows the rotated token was stored.
    • A forced refresh that races a tool call's refresh joins it: one grant is sent, both get the same token, and the connection keeps working afterward. With the gate bypassed (calling performTokenRefresh directly), this test fails with invalid_grant.
    • A dead grant reads expired / credential_refresh_rejected, is recorded, and is not re-sent.
    • Non-OAuth and unknown connections are rejected.
  • 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

  • Added a changeset (.changeset/connection-oauth-force-refresh.md).
  • Added or updated tests for the new behaviour.
  • No secrets, credentials, or private data in the diff.

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.
Copilot AI balanced review requested due to automatic review settings September 29, 2026 10:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

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.

[feature] Force an OAuth token refresh for a connection on demand

2 participants