Skip to content

feat(auth): silently refresh expired OAuth tokens#74

Closed
lmjabreu wants to merge 2 commits into
mainfrom
feat/oauth-token-refresh
Closed

feat(auth): silently refresh expired OAuth tokens#74
lmjabreu wants to merge 2 commits into
mainfrom
feat/oauth-token-refresh

Conversation

@lmjabreu
Copy link
Copy Markdown

Blocked on Doist/cli-core#36 — opened as draft. Marking ready once cli-core 0.17.0 is released and the dep bump lands here as a follow-up commit.

Summary

Wires the new @doist/cli-core/auth refreshAccessToken helper 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.exchangeCode now reads refresh_token and expires_in from Outline's /oauth/token response (previously discarded).
  • 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`.

Request path

  • getApiToken() now flows through refreshAccessToken (proactive: refresh when fewer than 60s remain on the access token). `OUTLINE_API_TOKEN` env still short-circuits with no refresh attempt.
  • New reactive 401-retry in api.ts: on a stored-token 401, force-refresh once and retry the request. A second 401 propagates untouched.
  • AUTH_REFRESH_EXPIRED / AUTH_REFRESH_UNAVAILABLE from cli-core collapse to the existing NoTokenError so users see one recovery hint ("run: ol auth login") instead of two competing codes.

Storage

  • StoredUser schema gained refresh_token, access_token_expires_at, refresh_token_expires_at (all optional).
  • Refresh tokens live in a sibling keyring slot (<user-id>/refresh) when the keyring is online; they fall back to refresh_token on the plaintext config record when the keyring is unavailable (mirrors the existing token / fallbackToken rule).

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

  • `npm run type-check`
  • `npm run lint:check` — 0 errors
  • `npm run format:check`
  • `npm test` — 182 passed (was 175), incl. 7 new tests for bundle persistence, refresh POST shape, error mapping, env-var short-circuit, and NoTokenError translation
  • Manual happy path: `ol auth login` captures refresh token; `ol auth status` shows expiry + "Refresh: enabled"; `ol doc list` after the access token expires triggers silent refresh
  • Manual 401 path: revoke the access token server-side; next `ol` call refreshes and retries
  • Manual revoked refresh: invalidate the refresh token; next `ol` call surfaces "No API token found" with the `ol auth login` hint
  • Manual env-var short-circuit: `OUTLINE_API_TOKEN=… ol auth status` — keyring untouched, source: env

🤖 Generated with Claude Code

lmjabreu and others added 2 commits May 18, 2026 23:02
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>
@scottlovegrove
Copy link
Copy Markdown
Contributor

Looking into this on the cli-core side as well as here. CLosing this one in favour of my work.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants