feat(auth): refresh an expiring OAuth token before token view / status - #63
Merged
Merged
Conversation
…atus` `tdc auth token view` printed whatever access token was stored, so once an OAuth token expired it kept handing scripts a dead token until some other command happened to refresh it. Wire cli-core 1.6.0's `refresh` option on both `token view` and `status` so an expiring token is rotated first. `status` no longer re-reads the store through `getApiTokenSnapshot` inside `fetchLive`; cli-core hands it the post-refresh token directly. The API path's own refresh now also threads `ref` through, so a `--user` snapshot rotates the selected account rather than the default one.
doistbot
reviewed
Sep 16, 2026
doistbot
left a comment
Member
There was a problem hiding this comment.
This PR wires cli-core 1.6.0's new refresh option into token view and status via a shared getTokenRefreshOptions() helper, so expiring OAuth tokens get rotated before being printed or probed, and threads the global --user ref through the API-path refresh. The env-var guard, handshake reuse, and lock-path/skew constants all line up with the existing auth flow, and no security or reuse issues surfaced.
Few things worth tightening:
- Preserve the legacy-token fallback: cli-core now resolves the handshake as soon as a bundle enters the 60-second refresh skew, so an OAuth record without
oauthClientIdthrows even while its access token is still valid —auth statusandauth token viewcan fail up to a minute earlier than API commands, which deliberately serve that token until it's actually expired. Defer the missing-metadata failure until refresh is truly needed, or have these attachers retain the valid token. - The headline
token viewrefresh path is untested: everytoken viewtest runs withgetTokenRefreshOptionsmocked to returnundefined, so therefreshoption onattachTokenViewCommandis never exercised — removing it (or a cli-core regression) would keep the suite green while a dead token prints again. Add a test mirroring the new status test: expiring OAuth bundle + fake provider, assert stdout prints the rotated token.
I also included a few optional follow-up notes in the details below.
Optional follow-up notes (3)
src/lib/auth.ts:74:
getTokenRefreshOptions()now owns the refresh wiring, butgetActiveSnapshotstill builds the sameprovider/skewMs/lockPath/handshakevalues inline when it callsrefreshAccessToken(lines 145-148). Reuse the builder there — e.g.refreshAccessToken({ store, ref, ...getTokenRefreshOptions() })— so the API path and the two attachers can't drift.src/commands/auth/auth.test.ts:626:
not.toHaveBeenCalledWith(undefined)is vacuous here.withUserRefAware.activeBundlesubstitutes the global ref (ref ?? requestedRef) before the underlyingstoreMocks.activeBundleis reached, so with--user 1the mock only ever sees'1'. This assertion cannot detect a missing ref on cli-core's under-lock re-read despite the comment above it. Drop it, or assert call count/arguments at the wrapper level.src/commands/auth/auth.test.ts:633: The comment says the rotated bundle is persisted, but the assertion only checks the new access token. The fake provider also returns
rt_refreshed_1234567890; if cli-core persisted the access token while dropping the rotated refresh token, this test would still pass and later refreshes would reuse the stale token. Assert the new refresh token (and expiry) as well, or narrow the comment.
…iring Drive cli-core's real refresh through `tdc auth token view` with a fake provider, assert the whole rotated pair is persisted on the status path, and drop an assertion the `withUserRefAware` wrapper made vacuous. The API path now spreads `getTokenRefreshOptions()` too so the three refresh call sites share one provider / lock path / skew.
doist-release-bot Bot
added a commit
that referenced
this pull request
Sep 16, 2026
## [3.3.0](v3.2.0...v3.3.0) (2026-09-16) ### Features * **auth:** refresh an expiring OAuth token before `token view` / `status` ([#63](#63)) ([c1926bd](c1926bd))
Contributor
|
🎉 This PR is included in version 3.3.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
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.
Problem
tdc auth token viewread the access token straight from the store and never triggered a refresh. Once an OAuth access token expired it kept printing the same dead token until some other API command happened to rotate it, which defeats the point ofexport COMMS_API_TOKEN="$(tdc auth token view)"for scripts and MCP servers.tdc auth statusworked around the same gap by re-reading the store throughgetApiTokenSnapshotinsidefetchLive.Change
Bumps
@doist/cli-coreto 1.6.0 (Doist/cli-core#135) and wires its newrefreshoption on both attachers via a sharedgetTokenRefreshOptions()insrc/lib/auth.ts. The DCR handshake is resolved per account through the function form, since cli-core doesn't persist the client metadata the refresh grant needs;getCommsOAuthRefreshHandshakestill raisesNO_TOKENfor partial metadata, so a stored OAuth token without a client id fails the same way it does on the API path rather than letting cli-core POST a grant with no client id.token view: rotates an expiring OAuth token before printing. Manual-token accounts andCOMMS_API_TOKENhave no refresh token and are served from the single bundle read, so their behaviour is unchanged. The env-var guard still runs before any refresh.status:fetchLivenow takes the token cli-core hands it instead of re-reading throughgetApiTokenSnapshot. One fewer store read, and the same fallback rules astoken view.getActiveSnapshot(the API path) now threadsrefintorefreshAccessToken, sotdc --user <ref> …rotates the selected account rather than the default one.Tests
auth.test.ts: the global--userstatus test now drives cli-core's real refresh with a fake provider and asserts the ref reaches every bundle read, the rotated bundle is persisted, andfetchLiveprobes with the new token.lib/auth.test.ts:getTokenRefreshOptionsreturns the expected lock path / skew and its handshake resolver delegates togetCommsOAuthRefreshHandshake, including theNO_TOKENrejection for partial metadata.Skill content and README updated for the
token viewbehaviour;SKILL.mdregenerated.