feat(auth): silent OAuth token refresh#75
Conversation
Adopts cli-core 0.19.0's refresh machinery so `ol` transparently rotates expired access tokens instead of forcing `ol auth login` every time the workspace's short-lived token expires. - Replaces the bespoke AuthProvider with `createPkceProvider` (cli-core now supports async resolvers + a custom-transport `fetchImpl`), so the refresh grant is inherited rather than hand-rolled. `exchangeCode` now captures `refresh_token` + `expires_in` instead of dropping them. - API client refreshes proactively (within the expiry skew, before a request) and reactively (force-refresh + single retry on a 401). `OUTLINE_API_TOKEN` env tokens are never refreshed. - `ol auth status` routes through the managed path so it self-heals an expired-but-refreshable token rather than reporting "expired". - `StoredUser` round-trips the bundle metadata (refresh_token, access_token_expires_at, refresh_token_expires_at, has_refresh_token); refresh tokens live in a sibling keyring slot. Requires the Outline OAuth app to be a public client (refresh sends no client_secret; a confidential client rejects the refresh grant). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
doistbot
left a comment
There was a problem hiding this comment.
This PR introduces a great improvement by adopting the latest cli-core refresh machinery to transparently rotate expired OAuth access tokens instead of requiring manual re-authentication. The implementation creates a much smoother user experience by seamlessly orchestrating proactive and reactive token refreshes within the API client. A few areas need adjustments, particularly around avoiding plaintext storage for long-lived refresh tokens, ensuring correct account context during multi-instance refreshes and status checks, deduplicating bundle storage logic, and expanding test coverage for the new refresh behaviors.
Security: - Never persist the refresh token to config in plaintext. It's a long-lived credential, so it stays in the secure store only; when the keyring is offline the account fails closed (re-auth on next expiry) rather than writing the token at rest. (Doist secrets standard.) Correctness: - `auth status` now refreshes the *selected* account (scoped via its id + base URL/client id through the refresh handshake), not the default — `--user <other>` checks/rotates the right account at the right instance. The PKCE provider's clientId resolver honours `handshake.clientId`. Hot path: - Restore parallel base-URL + token resolution; `proactiveRefresh` returns the token it resolved so unrefreshable/access-only accounts stay on a single store read (no redundant getApiToken). Dedup: - Extract `resolveAuth` (env→v2→legacy) shared by active/activeBundle and `writeThenDischargeLegacy` shared by set/setBundle. Tests: positive proactive-refresh path; public-client refresh form body (no client_secret); refresh-token-never-in-plaintext; auth.test back to rejects.toMatchObject. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## [1.8.0](v1.7.0...v1.8.0) (2026-05-21) ### Features * **auth:** silent OAuth token refresh ([#75](#75)) ([83996d5](83996d5))
|
🎉 This PR is included in version 1.8.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Summary
Adopts cli-core 0.19.0's refresh machinery so
oltransparently rotates expired access tokens instead of forcingol auth loginevery time the workspace's short-lived OAuth token expires.AuthProviderwithcreatePkceProvider(cli-core now supports async resolvers + a custom-transportfetchImpl, so outline's prompt-for-base-URL flow and undici dispatcher both work). The refresh grant is inherited rather than hand-rolled;exchangeCodenow capturesrefresh_token/expires_ininstead of discarding them.api.tsrefreshes proactively (within the expiry skew, before a request) and reactively (force-refresh + single retry on a 401). A token override or theOUTLINE_API_TOKENenv var is never refreshed.ol auth status— routes through the managed request path so it self-heals an expired-but-refreshable token rather than reporting "expired".StoredUserround-trips the bundle metadata (refresh_token,access_token_expires_at,refresh_token_expires_at,has_refresh_token); the refresh token lives in a sibling keyring slot, with a plaintext fallback when the keyring is offline.Requirement
The Outline OAuth app must be registered as a public client — refresh sends no
client_secret. A confidential client rejects the refresh grant withinvalid_request: Missing client_secret. (The handbook app has been switched to public.)Test plan
npm run type-check/lint:check/format:check/buildcleannpm test— 165 pass (new coverage: provider swap + refresh grant, store-wrappersetBundle/activeBundle,StoredUsermetadata round-trip, API 401-retry + env skip,reactiveRefresh→ re-login mapping)access_token_expires_at+has_refresh_token), and an expired token refreshes silently on the next command and onol auth status.Supersedes #74.
🤖 Generated with Claude Code