feat: configure transparent OAuth token refresh via auth.oauth - #1323
joshuaellis wants to merge 7 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📚 TypeDoc Generation Result✅ TypeDoc generated successfully!
The TypeDoc JSON file has been generated and validated. All documentation scripts completed successfully. |
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
8ee8ab0 to
d3395f1
Compare
|
|
||
| return connectEventSource(initEventSource, listenFor).pipe( | ||
| reconnectOnConnectionFailure(), | ||
| reconnectOnConnectionFailure(tokenSetup && getOAuthRefresher(tokenSetup)), |
There was a problem hiding this comment.
I think this will break resumability as it will trigger a new listener request, which instantiates a new EventSource rather than reusing the existing one, bypassing the existing EventSource's built-in error/retry mechanism (which sends the Last-Event-ID header on reconnect, telling the server where to resume from).
There was a problem hiding this comment.
I've had a look at producing tests which hopefully disproves your concern - let me know if i've missed a case worth having!
This comment was marked as outdated.
This comment was marked as outdated.
c2da3af to
07be524
Compare
07be524 to
5d6b7fb
Compare
| /** @defaultValue true */ | ||
| useCdn?: boolean | ||
| token?: string | ||
| token?: string | OAuthTokenSetup |
There was a problem hiding this comment.
this is potentially breaking, no? a consumer doing e.g. Bearer ${client.config().token}. Should we consider a different field for OauthTokenSetup?
There was a problem hiding this comment.
Yeah it would break TS because it could be an object instead of a string. I've updated to a separate field – lmk what you think.
| lastAuthRetryAt = Date.now() | ||
| return concat( | ||
| of({type: 'reconnect' as const}), | ||
| from(refreshAuth()).pipe( |
There was a problem hiding this comment.
not a huge deal I think, but this calls refreshAuth() eagerly. Would be better to use defer(refreshAuth) here to make it call refreshAuth at subscription time.
| for (const [key, value] of Object.entries(extraHeaders)) { | ||
| headers.set(key, value) | ||
| if (lastEventId && !headers.has('last-event-id')) { | ||
| headers.set('Last-Event-ID', lastEventId) |
There was a problem hiding this comment.
EventSource instances also tracks Last-Event-ID internally, how will these interact? We need to be extra careful here so we don't lose events or get duplicated delivery. Find it a bit hard to judge whether that's a real concern though 😅
There was a problem hiding this comment.
The wrapper only sets the header when the package hasn't (!headers.has('last-event-id')), and both values come from the same source (#lastEventId, surfaced as message.lastEventId). So on a live instance's own reconnects the package's header wins; the client-tracked value only seeds a fresh instance. Added a test in 0be6f0e for the case that would go wrong: fresh instance seeded with evt-1, receives evt-2, drops, and its own reconnect sends evt-2.
I've (claude) tried to add tests to cover this. Beyond that i'm not sure how else we can test this – open to ideas.
| // unhandled-rejection tracker flags a rejected promise in that gap. | ||
| // Attach a no-op handler synchronously; the rejection still propagates | ||
| // through the async return to the `eventsource` package's catch. | ||
| response.catch(() => {}) |
There was a problem hiding this comment.
This feels like a hack to me. Any way we can avoid this?
token for transparent refreshCo-authored-by: joshuaellis <37798644+joshuaellis@users.noreply.github.com>
1064163 to
1e9840c
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 1e9840c. Configure here.
commit: |

Description
Apps authenticating with short-lived OAuth access tokens would have to catch 401s, refresh, and re-create clients themselves which is annoying for everyone involved.
This lets the client own that instead: configure
auth.oauthwith anOAuthTokenSetupand requests, live streams, and uploads stay authenticated across token rotations without the app noticing. When a token genuinely can't be refreshed because the session is over then the app gets a singleonAuthErrorsignal to send the user back through login, instead of scattered 401s.Note
High Risk
Touches authentication across HTTP, uploads, and long-lived SSE connections, including refresh deduplication and 401/reconnect semantics that affect session security.
Overview
Adds
auth.oauth(OAuthTokenSetupwithgetToken,refresh, optionalgetExpiresAt/onAuthError) as an alternative to a statictoken, with config validation that the two cannot be combined.A new
oauthRefreshHandlerwraps the normal request pipeline: proactive refresh before expiry, single-flightrefresh()across concurrent callers, one automatic retry on 401 for fetch-based APIs, andonAuthErrorwhen refresh fails. The same setup is wired into paths that skip the default handler—asset uploads (fetch and XHR),listen/liveEventSource (Bearer resolved per fetch, including reconnects), plus 401-driven reconnect with token refresh andLast-Event-IDresume so SSE can continue after auth recovery.Uploads do not auto-retry after 401 (body may be consumed); they still refresh so a caller retry gets a new token.
Reviewed by Cursor Bugbot for commit 371fd57. Bugbot is set up for automated code reviews on this repo. Configure here.