Skip to content

Commit acd8bb3

Browse files
bidahclaude
andcommitted
🔧 fix(x-bot): persist OAuth 2.0 refresh token in database to survive rotation
Twitter OAuth 2.0 rotates refresh tokens on each use, invalidating the previous one. Storing the token only in an env var meant the second poll call always failed. Now the refresh token is read from/written to the xBotState DB table, with the env var as initial seed fallback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 25d3dc2 commit acd8bb3

3 files changed

Lines changed: 73 additions & 15 deletions

File tree

‎apps/web/app/(app)/api/x-bot/auth/callback/route.ts‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
// app/api/auth/callback/route.ts
22
import { NextResponse } from 'next/server';
33
import { cookies } from 'next/headers';
4+
import { db } from '@/lib/db';
5+
import { xBotState } from '@react-native-vibe-code/database';
6+
import { eq } from 'drizzle-orm';
47

58
export async function GET(request: Request) {
69
const url = new URL(request.url);
@@ -62,8 +65,29 @@ export async function GET(request: Request) {
6265
const data = await response.json();
6366
const refreshToken = data.refresh_token;
6467

65-
// Return the refresh token (and optionally other data)
66-
return NextResponse.json({ refresh_token: refreshToken });
68+
// Store refresh token in database so it survives rotation
69+
if (refreshToken) {
70+
const existing = await db
71+
.select()
72+
.from(xBotState)
73+
.where(eq(xBotState.id, 'default'))
74+
.limit(1);
75+
76+
if (existing.length === 0) {
77+
await db.insert(xBotState).values({
78+
id: 'default',
79+
refreshToken,
80+
updatedAt: new Date(),
81+
});
82+
} else {
83+
await db
84+
.update(xBotState)
85+
.set({ refreshToken, updatedAt: new Date() })
86+
.where(eq(xBotState.id, 'default'));
87+
}
88+
}
89+
90+
return NextResponse.json({ refresh_token: refreshToken, saved_to_db: true });
6791
} catch (error) {
6892
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
6993
}

‎apps/web/lib/x-bot/process-mention.ts‎

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Client, auth } from 'twitter-api-sdk'
22
import { db } from '@/lib/db'
3-
import { xBotReplies, twitterLinks } from '@react-native-vibe-code/database'
3+
import { xBotReplies, xBotState, twitterLinks } from '@react-native-vibe-code/database'
44
import { eq } from 'drizzle-orm'
55
import { classifyTweet, quickAppRequestCheck } from '@/lib/x-bot/classify-tweet'
66
import { downloadAndStoreTweetImages } from '@/lib/x-bot/extract-images'
@@ -80,12 +80,48 @@ async function updateTweet(
8080
await db.update(xBotReplies).set(data).where(eq(xBotReplies.tweetId, tweetId))
8181
}
8282

83+
// Get the current refresh token — prefer DB (survives rotation), fall back to env var
84+
async function getStoredRefreshToken(): Promise<string> {
85+
const rows = await db
86+
.select()
87+
.from(xBotState)
88+
.where(eq(xBotState.id, 'default'))
89+
.limit(1)
90+
91+
const dbToken = rows[0]?.refreshToken
92+
if (dbToken) return dbToken
93+
94+
const envToken = process.env.TWITTER_REFRESH_TOKEN
95+
if (envToken) return envToken
96+
97+
throw new Error('No refresh token found in database or TWITTER_REFRESH_TOKEN env var')
98+
}
99+
100+
// Persist the rotated refresh token to the database
101+
async function saveRefreshToken(token: string): Promise<void> {
102+
const existing = await db
103+
.select()
104+
.from(xBotState)
105+
.where(eq(xBotState.id, 'default'))
106+
.limit(1)
107+
108+
if (existing.length === 0) {
109+
await db.insert(xBotState).values({
110+
id: 'default',
111+
refreshToken: token,
112+
updatedAt: new Date(),
113+
})
114+
} else {
115+
await db
116+
.update(xBotState)
117+
.set({ refreshToken: token, updatedAt: new Date() })
118+
.where(eq(xBotState.id, 'default'))
119+
}
120+
}
121+
83122
// Get authenticated Twitter client
84123
export async function getAuthClient(): Promise<Client> {
85-
const refreshToken = process.env.TWITTER_REFRESH_TOKEN
86-
if (!refreshToken) {
87-
throw new Error('TWITTER_REFRESH_TOKEN environment variable is required')
88-
}
124+
const refreshToken = await getStoredRefreshToken()
89125

90126
const oauth2Client = new auth.OAuth2User({
91127
client_id: process.env.TWITTER_CLIENT_ID as string,
@@ -97,14 +133,11 @@ export async function getAuthClient(): Promise<Client> {
97133
oauth2Client.token = { refresh_token: refreshToken }
98134
await oauth2Client.refreshAccessToken()
99135

100-
if (
101-
oauth2Client.token?.refresh_token &&
102-
oauth2Client.token.refresh_token !== refreshToken
103-
) {
104-
console.log(
105-
'WARNING: Refresh token rotated. Update TWITTER_REFRESH_TOKEN:',
106-
oauth2Client.token.refresh_token
107-
)
136+
// Persist rotated refresh token to survive across invocations
137+
const newRefreshToken = oauth2Client.token?.refresh_token
138+
if (newRefreshToken && newRefreshToken !== refreshToken) {
139+
console.log('[X-Bot] Refresh token rotated — saving to database')
140+
await saveRefreshToken(newRefreshToken)
108141
}
109142

110143
return new Client(oauth2Client)

‎packages/database/src/schema.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,7 @@ export const xBotReplies = pgTable('x_bot_replies', {
443443
export const xBotState = pgTable('x_bot_state', {
444444
id: text('id').primaryKey().default('default'), // Single row with id='default'
445445
lastTweetId: text('last_tweet_id'), // Last processed tweet ID for since_id
446+
refreshToken: text('refresh_token'), // OAuth 2.0 refresh token (rotates on each use)
446447
updatedAt: timestamp('updated_at').defaultNow(),
447448
})
448449

0 commit comments

Comments
 (0)