From 4e13506b4ee8d266eff567fc7e0895ef69ac1abd Mon Sep 17 00:00:00 2001 From: PG2047 Date: Sat, 30 May 2026 10:12:35 +0800 Subject: [PATCH] feat(auth): refresh OAuth2 access token instead of failing on 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. --- src/bookmarks.ts | 17 +++++++++--- src/xauth.ts | 68 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/src/bookmarks.ts b/src/bookmarks.ts index 91129e4..22d52d1 100644 --- a/src/bookmarks.ts +++ b/src/bookmarks.ts @@ -2,7 +2,7 @@ import { ensureDir, pathExists, readJson, readJsonLines, writeJson, writeJsonLin import { ensureDataDir, twitterBackfillStatePath, twitterBookmarksCachePath, twitterBookmarksMetaPath } from './paths.js'; import type { BookmarkBackfillState, BookmarkCacheMeta, BookmarkRecord } from './types.js'; import { loadXApiConfig } from './config.js'; -import { loadTwitterOAuthToken } from './xauth.js'; +import { ensureValidTwitterToken, refreshTwitterTokenNow } from './xauth.js'; export interface BookmarkSyncResult { mode: 'full' | 'incremental'; @@ -171,12 +171,21 @@ export async function syncTwitterBookmarks( mode: 'full' | 'incremental', options: { targetAdds?: number } = {} ): Promise { - const token = await loadTwitterOAuthToken(); + const token = await ensureValidTwitterToken(); if (!token?.access_token) { throw new Error('Missing user-context OAuth token. Run: ft auth'); } - const me = await fetchCurrentUserId(token.access_token); + let accessToken = token.access_token; + let me = await fetchCurrentUserId(accessToken); + if (!me.ok && me.status === 401) { + // Access token may have been revoked or expired ahead of its stated lifetime; refresh once and retry. + const refreshed = await refreshTwitterTokenNow(); + if (refreshed?.access_token) { + accessToken = refreshed.access_token; + me = await fetchCurrentUserId(accessToken); + } + } if (!me.ok || !me.id) { throw new Error(`Could not resolve current user id: ${me.detail}`); } @@ -194,7 +203,7 @@ export async function syncTwitterBookmarks( const maxPages = mode === 'full' ? 200 : 2; while (pages < maxPages) { - const pageResult = await fetchBookmarksPage(token.access_token, me.id, nextToken); + const pageResult = await fetchBookmarksPage(accessToken, me.id, nextToken); if (!pageResult.ok || !pageResult.page) { throw new Error(`Bookmark fetch failed (${pageResult.status}): ${pageResult.detail}`); } diff --git a/src/xauth.ts b/src/xauth.ts index 6058bc3..59cf33b 100644 --- a/src/xauth.ts +++ b/src/xauth.ts @@ -76,6 +76,74 @@ async function exchangeCodeForToken(code: string, verifier: string): Promise { + const cfg = loadXApiConfig(); + const basic = Buffer.from(`${cfg.clientId}:${cfg.clientSecret}`).toString('base64'); + const body = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: cfg.clientId, + }); + + const response = await fetch('https://api.x.com/2/oauth2/token', { + method: 'POST', + headers: { + Authorization: `Basic ${basic}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }); + + const text = await response.text(); + if (!response.ok) { + throw new Error(`Token refresh failed (HTTP ${response.status}). Run: ft auth`); + } + let parsed: any; + try { + parsed = JSON.parse(text); + } catch { + throw new Error(`Token refresh returned a non-JSON response (HTTP ${response.status}). Run: ft auth`); + } + + return { + access_token: parsed.access_token, + // X rotates the refresh token on each refresh; keep the prior one only if the response omits it. + refresh_token: parsed.refresh_token ?? refreshToken, + expires_in: parsed.expires_in, + scope: parsed.scope, + token_type: parsed.token_type, + obtained_at: new Date().toISOString(), + }; +} + +function isAccessTokenExpired(token: XOAuthTokenSet, bufferSeconds = 300): boolean { + if (!token.expires_in) return false; + const obtainedMs = Date.parse(token.obtained_at); + if (Number.isNaN(obtainedMs)) return true; + const expiresAtMs = obtainedMs + token.expires_in * 1000; + return Date.now() >= expiresAtMs - bufferSeconds * 1000; +} + +export async function ensureValidTwitterToken(): Promise { + const token = await loadTwitterOAuthToken(); + if (!token?.access_token) return token; + if (!isAccessTokenExpired(token)) return token; + if (!token.refresh_token) return token; + const refreshed = await requestTokenRefresh(token.refresh_token); + // Persist the rotated token set before using it; writeJson is atomic (tmp + rename), + // so an interrupted write cannot strand us with the now-invalidated old refresh token. + await saveTwitterOAuthToken(refreshed); + return refreshed; +} + +export async function refreshTwitterTokenNow(): Promise { + const token = await loadTwitterOAuthToken(); + if (!token?.refresh_token) return null; + const refreshed = await requestTokenRefresh(token.refresh_token); + await saveTwitterOAuthToken(refreshed); + return refreshed; +} + export async function saveTwitterOAuthToken(token: XOAuthTokenSet): Promise { ensureDataDir(); const tokenPath = twitterOauthTokenPath();