feat(auth): refresh OAuth2 access token instead of failing on expiry#164
feat(auth): refresh OAuth2 access token instead of failing on expiry#164PG2047 wants to merge 1 commit into
Conversation
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.
There was a problem hiding this comment.
💡 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".
| const pageResult = await fetchBookmarksPage(accessToken, me.id, nextToken); | ||
| if (!pageResult.ok || !pageResult.page) { | ||
| throw new Error(`Bookmark fetch failed (${pageResult.status}): ${pageResult.detail}`); |
There was a problem hiding this comment.
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 👍 / 👎.
Problem
ftonly ever performs theauthorization_codegrant. The OAuth2 access token it stores has a short lifetime (expires_in, ~2h for X), and nothing ever refreshes it. Therefresh_tokenreturned by X is saved to the token file but never used.As a result, any
ft sync --apirun more than ~2 hours afterft authfails:This makes unattended or scheduled bookmark syncs (cron / launchd) impossible — the interactive
ft authbrowser flow would have to be re-run every couple of hours.Fix
Implement the
refresh_tokengrant flow:requestTokenRefresh()— exchanges the stored refresh token for a new access token athttps://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.writeJsonis atomic (tmp +rename), so an interrupted write cannot strand the now-invalidated old refresh token.refreshTwitterTokenNow()+ a 401 retry insyncTwitterBookmarks()— 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_atwere already stored).Testing
With an access token already past its
expires_in,ft sync --apipreviously failed every time with 401. After this change the same run refreshes the token (the token file'sobtained_atupdates) and the sync completes; subsequent runs reuse the still-valid token until it nears expiry.