Repository navigation
fix(auth): keep plaintext credentials in plaintext across silent refresh - #157
Merged
Merged
Conversation
The credential-store policy is resolved per invocation from the CLI flag, so a background refresh ran with the consumer's default policy. With the keyring reachable at that moment, the rotated tokens moved into the keyring and the plaintext copy chosen at login was dropped, breaking later reads from contexts where the keyring is unreachable (scheduled jobs during a macOS dark wake). setBundle now takes a preserveStorage option, passed by refreshAccessToken. When the existing record holds a plaintext token, the keyring store writes the rotated bundle back to plaintext instead of applying the configured policy. Fixes #155
doistbot
reviewed
Oct 24, 2026
doistbot
left a comment
Member
There was a problem hiding this comment.
This PR keeps credentials stored as plaintext via --credential-store plaintext in plaintext across silent token refreshes by having the keyring store infer the existing record's storage backend and reuse it on rotated writes. Well-targeted fix, and the tests and README cover the behavior change.
Few things worth tightening:
storedCredentialStoretreats the mere presence offallbackToken/fallbackRefreshTokenas proof of plaintext storage, but the read side only trusts non-empty trimmed values. A keyring-backed record carrying an empty/nullfallbackTokenwould read from the keyring today, then get silently downgraded to plaintext on the next refresh — suggest matching the read semantics (e.g. checkexisting?.fallbackToken?.trim() || existing?.fallbackRefreshToken?.trim()).
I also included a few optional follow-up notes in the details below.
Optional follow-up notes (5)
src/auth/types.ts:118: This JSDoc says
preserveStoragekeeps the credential in the backend that already holds it, butcreateKeyringTokenStore.setBundleonly pins plaintext records: a keyring-held record still followsresolveCredentialStore()(so configured'plaintext', or'fallback'with the keyring offline, moves it to plaintext). That asymmetry is intended per the PR description, but the exported contract (here and in theTokenStore.setBundledoc below) should say something like "keep a plaintext credential in plaintext; records in the credential manager keep the configured policy" so implementers don't rely on the broader promise.src/auth/persist.ts:17:
PersistBundleOptionsredeclares the forwardedsetBundleoption fields instead of reusing the newSetBundleOptionstype. Change it toSetBundleOptions & { store: TokenStore<TAccount>; account: TAccount; bundle: TokenBundle }sopromoteDefaultandpreserveStoragecan't fall out of sync withTokenStore.setBundle.src/auth/keyring/token-store.ts:270: This plaintext-record predicate repeats the exact expression already used in
clear()'s fallback detection. Extract one helper (for examplehasPlaintextFallback(record)) and call it from both places so the definition of a plaintext-held record has a single source.src/auth/keyring/token-store.test.ts:613: This test only covers a record with both fallback fields.
storedCredentialStoretreats each field independently (fallbackToken !== undefined || fallbackRefreshToken !== undefined), so thefallbackRefreshToken-only branch is unprotected: a record where the access slot wrote to the keyring but the refresh slot fell back would move back into the keyring on refresh if that check regressed, and the suite would still pass. Add a fixture with onlyfallbackRefreshTokenset.src/auth/keyring/token-store.test.ts:635: This test runs under the default
'fallback'policy, so it does not actually exercise the configured-policy branch for a keyring-held record. A regression that hardcodes the keyring path forpreserveStoragewould pass, and a user who switched to--credential-store=plaintextwould keep their secret in the keyring on refresh. Add a variant with a non-defaultcredentialStore(e.g.'plaintext') and assert the rotated bundle lands infallbackToken/fallbackRefreshToken.
An empty or whitespace fallback field is ignored by the readers, so it must not pin a keyring-backed record to plaintext on refresh either. Share one hasPlaintextCredential predicate between setBundle's preserveStorage path and clear()'s fallback detection. Narrow the preserveStorage docs to what the keyring store does, reuse SetBundleOptions in PersistBundleOptions, and cover the refresh-only fallback, empty fallback and non-default policy cases.
doist-release-bot Bot
added a commit
that referenced
this pull request
Oct 24, 2026
## [1.7.2](v1.7.1...v1.7.2) (2026-10-24) ### Bug Fixes * **auth:** keep plaintext credentials in plaintext across silent refresh ([#157](#157)) ([6001b63](6001b63)), closes [#155](#155) * **auth:** require Secret Service on Linux instead of the session keyring ([#158](#158)) ([f27add1](f27add1)), closes [Doist/todoist-cli#577](Doist/todoist-cli#577)
Contributor
|
🎉 This PR is included in version 1.7.2 🎉 The release is available on: Your semantic-release bot 📦🚀 |
3 tasks done
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
Fixes #155.
--credential-store plaintextonly lasted until the next token refresh.The credential-store policy is resolved per invocation from the CLI flag and is never persisted.
refreshAccessTokenwrites the rotated bundle throughpersistBundle→setBundle, and that write ran with the consumer's default policy ('fallback'in comms-cli). If the keyring was reachable at that moment, the rotated tokens moved into the keyring and the record was upserted withoutfallbackToken, so the plaintext copy the user picked at login was gone. The next read from a context where the keyring is unreachable (a scheduled job during a macOS dark wake) then failed.Changes
SetBundleOptionstype (promoteDefault?,preserveStorage?), exported from@doist/cli-core/auth, used byTokenStore.setBundleandKeyringTokenStore.setBundle.persistBundleforwardspreserveStorage, and still leaves out the options argument when nothing is set.refreshAccessTokenpassespreserveStorage: true.createKeyringTokenStore().setBundlewithpreserveStorage: if the existing record holdsfallbackTokenorfallbackRefreshToken, the rotated bundle is written as plaintext, whatever the configured policy. Records held in the credential manager keep the configured policy.Login and
auth tokenwrites still use the configured policy, so an explicit choice at login still wins and a user can move a credential back into the keyring by logging in again.The storage is inferred from the existing record, not persisted as a marker, so no consumer
UserRecordStoreor config schema has to change. As a result, a record that fell back to plaintext under'fallback'(keyring unavailable at login) also stays in plaintext until the next login, where before it moved into the keyring on refresh. That matches what the dark-wake case needs.Related
Doist/todoist-cli#577 (Linux without Secret Service lands in the per-session kernel keyring) will be fixed in a stacked PR. Its remedy is to point users at
--credential-store=plaintext, and for CLIs that refresh tokens that only works with this fix in place.End-to-end testing
Tested end to end against comms-cli (
tdc) main, with a throwawayXDG_CONFIG_HOMEand a real OAuth login:tdc auth login --credential-store plaintext, then setaccessTokenExpiresAtto the past and runtdc auth statusto force a silent refresh.tdc doctorreported "via secure-store" with no warning.tdc doctorstill reported "token is stored in plaintext config fallback".Test plan
npm test,npm run type-check,npm run checktoken-storetests: plaintext record +preserveStoragestays plaintext with the keyring online (fails without the fix); keyring record keeps the configured policy; withoutpreserveStoragethe configured policy appliespersist/refreshtests cover forwardingpreserveStorage: true