feat(auth): silently refresh expired OAuth tokens#74
Closed
lmjabreu wants to merge 2 commits into
Closed
Conversation
Wire the @doist/cli-core/auth `refreshAccessToken` helper into ol's request
hot path so access tokens that expire mid-session are refreshed
transparently instead of forcing a manual `ol auth login`.
- exchangeCode now persists `refresh_token` + `expires_in` from Outline's
/oauth/token response (they were being dropped on the floor before).
- New createOutlineAuthProvider.refreshToken POSTs `grant_type=refresh_token`
against `${account.baseUrl}/oauth/token` using the stored OAuth client_id
(public-client refresh, no client_secret).
- getApiToken now flows through refreshAccessToken with a 60s skew window;
OUTLINE_API_TOKEN env still short-circuits with no refresh attempt.
- New reactive 401-retry path in api.ts: on a stored-token 401, force-refresh
once and retry the request. A second 401 propagates untouched.
- StoredUser schema gained `refresh_token`, `access_token_expires_at`,
`refresh_token_expires_at` (all optional).
- `ol auth status` now shows "Access token expires: in 12m" and
"Refresh: enabled" when applicable, plus matching --json fields.
- AUTH_REFRESH_EXPIRED / AUTH_REFRESH_UNAVAILABLE collapse to the existing
NoTokenError so users see one recovery hint instead of two competing codes.
Existing v1.7.0 users will get a one-time forced re-login the first time
their stored access token expires, since their record predates the refresh
token slot. After that, silent refresh kicks in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ath)
- exchangeCode + refreshToken return `expiresAt` (matches the restored
cli-core ExchangeResult field — no rename to accessTokenExpiresAt).
- createOutlineTokenStore wrapper restores set(account, token: string)
and adds setBundle(account, bundle) delegating to inner.setBundle, so
the wrapper satisfies both the base TokenStore contract and the new
optional bundle path.
- getApiTokenForceRefresh passes an explicit lockPath
(`${getConfigPath()}.refresh.lock`) since cli-core no longer derives
it from recordsLocation.
- Remove the no-longer-exported getRecordsLocation pass-through.
- Test fixture's expectations updated to read `result.expiresAt` (not
`accessTokenExpiresAt`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 tasks
8 tasks
Contributor
|
Looking into this on the cli-core side as well as here. CLosing this one in favour of my work. |
3 tasks
scottlovegrove
added a commit
that referenced
this pull request
May 21, 2026
## 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 path** — `api.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". - **Storage** — `StoredUser` 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 - [x] `npm run type-check` / `lint:check` / `format:check` / `build` clean - [x] `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) - [x] 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](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
Wires the new
@doist/cli-core/authrefreshAccessTokenhelper into ol's request hot path so OAuth access tokens that expire mid-session are refreshed transparently. No more manual `ol auth login` every few days when the Outline workspace's OAuth client has a short access-token lifetime (set by the workspace admin, not user-configurable).Context: Outline issues short-lived access tokens + long-lived refresh tokens. ol-cli was dropping the refresh token on the floor at login and forcing a re-auth every time the access token expired. This PR plugs the gap.
What changed
Login path
createOutlineAuthProvider.exchangeCodenow readsrefresh_tokenandexpires_infrom Outline's/oauth/tokenresponse (previously discarded).createOutlineAuthProvider.refreshTokenPOSTsgrant_type=refresh_tokenagainst `${account.baseUrl}/oauth/token` using the stored OAuthclient_id. Public-client refresh, no `client_secret`.Request path
getApiToken()now flows throughrefreshAccessToken(proactive: refresh when fewer than 60s remain on the access token). `OUTLINE_API_TOKEN` env still short-circuits with no refresh attempt.api.ts: on a stored-token 401, force-refresh once and retry the request. A second 401 propagates untouched.AUTH_REFRESH_EXPIRED/AUTH_REFRESH_UNAVAILABLEfrom cli-core collapse to the existingNoTokenErrorso users see one recovery hint ("run: ol auth login") instead of two competing codes.Storage
StoredUserschema gainedrefresh_token,access_token_expires_at,refresh_token_expires_at(all optional).<user-id>/refresh) when the keyring is online; they fall back torefresh_tokenon the plaintext config record when the keyring is unavailable (mirrors the existingtoken/fallbackTokenrule).Status output
`ol auth status` now shows:
```
✓ Authenticated
Team: Doist
User: Luis Abreu (luis@doist.com)
Base URL: https://doist.getoutline.com
Token source: secure-store
Access token expires: in 12m
Refresh: enabled (silent re-auth on expiry)
```
`--json` payload gains `accessTokenExpiresAt` and `hasRefreshToken`.
Migration
Existing v1.7.0 users will get a one-time forced re-login the first time their stored access token expires (their record predates the refresh token slot). After that, silent refresh kicks in for the lifetime of the refresh token.
Test plan
🤖 Generated with Claude Code