Skip to content

feat(auth): silent OAuth token refresh#75

Merged
scottlovegrove merged 2 commits into
mainfrom
scottl/silent-token-refresh
May 21, 2026
Merged

feat(auth): silent OAuth token refresh#75
scottlovegrove merged 2 commits into
mainfrom
scottl/silent-token-refresh

Conversation

@scottlovegrove
Copy link
Copy Markdown
Contributor

Summary

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 OAuth token expires.

  • Provider — replaces the bespoke AuthProvider with createPkceProvider (cli-core now supports async resolvers + a custom-transport fetchImpl, so outline's prompt-for-base-URL flow and undici dispatcher both work). The refresh grant is inherited rather than hand-rolled; exchangeCode now captures refresh_token / expires_in instead of discarding them.
  • Request pathapi.ts refreshes proactively (within the expiry skew, before a request) and reactively (force-refresh + single retry on a 401). A token override or the OUTLINE_API_TOKEN env 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".
  • StorageStoredUser round-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 with invalid_request: Missing client_secret. (The handbook app has been switched to public.)

Test plan

  • npm run type-check / lint:check / format:check / build clean
  • npm test — 165 pass (new coverage: provider swap + refresh grant, store-wrapper setBundle/activeBundle, StoredUser metadata round-trip, API 401-retry + env skip, reactiveRefresh → re-login mapping)
  • Validated end-to-end against a public handbook OAuth app: login persists the bundle (keyring access + refresh slots; record carries access_token_expires_at + has_refresh_token), and an expired token refreshes silently on the next command and on ol auth status.

Supersedes #74.

🤖 Generated with Claude Code

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>
@scottlovegrove scottlovegrove self-assigned this May 21, 2026
Copy link
Copy Markdown
Member

@doistbot doistbot left a comment

Choose a reason for hiding this comment

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

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.

Share FeedbackReview Logs

Comment thread src/commands/auth.ts Outdated
Comment thread src/lib/auth-provider.ts Outdated
Comment thread src/lib/auth-provider.ts Outdated
Comment thread src/lib/auth-provider.ts
Comment thread src/lib/api.ts
Comment thread src/lib/api.ts Outdated
Comment thread src/__tests__/auth.test.ts
Comment thread src/__tests__/api.test.ts
Comment thread src/__tests__/auth-provider.test.ts
Comment thread src/lib/user-records.ts Outdated
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>
@scottlovegrove scottlovegrove requested review from a team and rmartins90 and removed request for a team May 21, 2026 10:49
@scottlovegrove scottlovegrove added the 👀 Show PR PR must be reviewed before or after merging label May 21, 2026
@scottlovegrove scottlovegrove merged commit 83996d5 into main May 21, 2026
5 checks passed
@scottlovegrove scottlovegrove deleted the scottl/silent-token-refresh branch May 21, 2026 10:50
doist-release-bot Bot added a commit that referenced this pull request May 21, 2026
## [1.8.0](v1.7.0...v1.8.0) (2026-05-21)

### Features

* **auth:** silent OAuth token refresh ([#75](#75)) ([83996d5](83996d5))
@doist-release-bot
Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 1.8.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Copy link
Copy Markdown

@rmartins90 rmartins90 left a comment

Choose a reason for hiding this comment

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

released 👀 Show PR PR must be reviewed before or after merging

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants