Skip to content

feat(auth): refresh OAuth2 access token instead of failing on expiry#164

Open
PG2047 wants to merge 1 commit into
afar1:mainfrom
PG2047:pr-oauth-refresh
Open

feat(auth): refresh OAuth2 access token instead of failing on expiry#164
PG2047 wants to merge 1 commit into
afar1:mainfrom
PG2047:pr-oauth-refresh

Conversation

@PG2047
Copy link
Copy Markdown
Contributor

@PG2047 PG2047 commented May 30, 2026

Problem

ft only ever performs the authorization_code grant. The OAuth2 access token it stores has a short lifetime (expires_in, ~2h for X), and nothing ever refreshes it. The refresh_token returned by X is saved to the token file but never used.

As a result, any ft sync --api run more than ~2 hours after ft auth fails:

Could not resolve current user id: {"title":"Unauthorized","type":"about:blank","status":401,"detail":"Unauthorized"}

This makes unattended or scheduled bookmark syncs (cron / launchd) impossible — the interactive ft auth browser flow would have to be re-run every couple of hours.

Fix

Implement the refresh_token grant flow:

  • requestTokenRefresh() — exchanges the stored refresh token for a new access token at https://api.x.com/2/oauth2/token. It checks the HTTP status before parsing and reports non-JSON error bodies without masking the status. X rotates the refresh token on each refresh, so the new refresh token is persisted (falling back to the prior one only if the response omits it).
  • ensureValidTwitterToken() — proactively refreshes when the access token is within 5 minutes of expiry, then persists the rotated token set before using it. writeJson is atomic (tmp + rename), so an interrupted write cannot strand the now-invalidated old refresh token.
  • refreshTwitterTokenNow() + a 401 retry in syncTwitterBookmarks() — if the first authenticated request still returns 401 (token revoked server-side, or local clock skew), refresh once and retry within the same run.

No new dependencies. The token file schema is unchanged (refresh_token / expires_in / obtained_at were already stored).

Testing

With an access token already past its expires_in, ft sync --api previously failed every time with 401. After this change the same run refreshes the token (the token file's obtained_at updates) and the sync completes; subsequent runs reuse the still-valid token until it nears expiry.

ft only performed the authorization_code grant, so the stored access
token expired after its lifetime (expires_in, ~2h) and every subsequent
"ft sync --api" failed with 401 Unauthorized. The refresh_token returned
by X was saved but never used, making unattended/scheduled syncs
impossible.

This adds the refresh_token grant flow:

- requestTokenRefresh(): exchange the stored refresh token for a new
  access token; check HTTP status before parsing and surface non-JSON
  error responses without masking the status. X rotates the refresh
  token on each refresh, so the rotated value is preserved.
- ensureValidTwitterToken(): proactively refresh when the access token
  is within 5 min of expiry, persisting the rotated token set
  atomically (writeJson tmp+rename) before use.
- refreshTwitterTokenNow() + a 401 retry in syncTwitterBookmarks(): if
  the first authenticated request still 401s (token revoked or clock
  skew), refresh once and retry within the same run.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4e13506b4e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/bookmarks.ts
Comment on lines +206 to 208
const pageResult = await fetchBookmarksPage(accessToken, me.id, nextToken);
if (!pageResult.ok || !pageResult.page) {
throw new Error(`Bookmark fetch failed (${pageResult.status}): ${pageResult.detail}`);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Refresh after 401s during bookmark pagination

When a full API sync starts with a token that is still outside the 5-minute refresh window but expires while walking pages, the initial /2/users/me retry path never runs again and a 401 from fetchBookmarksPage immediately aborts the sync. This can happen on large mode === 'full' runs (maxPages is 200) or slow/rate-limited requests, so unattended syncs can still fail mid-run even though a refresh token is available; refresh and retry the current page once on a 401 here as well.

Useful? React with 👍 / 👎.

PG2047 added a commit to PG2047/fieldtheory-cli that referenced this pull request May 30, 2026
…#164

Update HANDOFF/FORK_NOTES for the 2026-05-30 work: OAuth2 refresh_token
grant flow (PR afar1#164), x.com authorize endpoint (PR afar1#162, merged), and
local self-use tuning. Correct the commit-author gotcha to require the
noreply email.
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.

1 participant