From 2306fb1914ff121514a7d32fe1598a7263eb1470 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:35:04 -0700 Subject: [PATCH] feat(growth): migrate X posting from OAuth 1.0a to OAuth 2.0 PKCE (#3329) - Replace OAuth 1.0a signing with OAuth 2.0 Bearer token auth - Add x-auth.ts: one-time PKCE authorization flow that saves tokens to state.db - Add auto-refresh: tokens refresh transparently when expired (2hr TTL) - Add x_tokens table to state.db schema (via helpers.ts openDb) - Env vars simplified: X_CLIENT_ID + X_CLIENT_SECRET (no more 4 keys) - x-post.ts rewritten to read tokens from DB, refresh if needed Co-authored-by: Claude Co-authored-by: Claude Opus 4.6 (1M context) --- .claude/skills/setup-agent-team/x-auth.ts | 175 +++++++++++++++++++++ .claude/skills/setup-agent-team/x-post.ts | 178 ++++++++++++++-------- .claude/skills/setup-spa/helpers.ts | 9 ++ .claude/skills/setup-spa/main.ts | 153 ++++++++++++++----- 4 files changed, 415 insertions(+), 100 deletions(-) create mode 100644 .claude/skills/setup-agent-team/x-auth.ts diff --git a/.claude/skills/setup-agent-team/x-auth.ts b/.claude/skills/setup-agent-team/x-auth.ts new file mode 100644 index 00000000..f10cddf1 --- /dev/null +++ b/.claude/skills/setup-agent-team/x-auth.ts @@ -0,0 +1,175 @@ +/** + * X OAuth 2.0 PKCE Authorization — One-time setup. + * + * Starts a local server, opens the X authorization URL, receives the callback, + * exchanges the code for access + refresh tokens, and saves them to state.db. + * + * Usage: + * X_CLIENT_ID=... X_CLIENT_SECRET=... bun run x-auth.ts + * + * After running, the SPA and growth scripts will use the stored tokens automatically. + */ + +import { Database } from "bun:sqlite"; +import { createHash, randomBytes } from "node:crypto"; +import { existsSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +const CLIENT_ID = process.env.X_CLIENT_ID ?? ""; +const CLIENT_SECRET = process.env.X_CLIENT_SECRET ?? ""; +const PORT = 8739; +const REDIRECT_URI = `http://127.0.0.1:${PORT}/callback`; +const SCOPES = "tweet.read tweet.write users.read offline.access"; + +if (!CLIENT_ID || !CLIENT_SECRET) { + console.error("[x-auth] X_CLIENT_ID and X_CLIENT_SECRET are required"); + process.exit(1); +} + +const DB_PATH = `${process.env.HOME ?? "/tmp"}/.config/spawn/state.db`; + +function openTokenDb(): Database { + const dir = dirname(DB_PATH); + if (!existsSync(dir)) + mkdirSync(dir, { + recursive: true, + }); + const db = new Database(DB_PATH); + db.run("PRAGMA journal_mode = WAL"); + db.run(` + CREATE TABLE IF NOT EXISTS x_tokens ( + id INTEGER PRIMARY KEY CHECK (id = 1), + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + expires_at INTEGER NOT NULL, + updated_at TEXT NOT NULL + ) + `); + return db; +} + +function generatePKCE(): { + verifier: string; + challenge: string; +} { + const verifier = randomBytes(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { + verifier, + challenge, + }; +} + +const { verifier, challenge } = generatePKCE(); +const state = randomBytes(16).toString("hex"); + +const authUrl = new URL("https://x.com/i/oauth2/authorize"); +authUrl.searchParams.set("response_type", "code"); +authUrl.searchParams.set("client_id", CLIENT_ID); +authUrl.searchParams.set("redirect_uri", REDIRECT_URI); +authUrl.searchParams.set("scope", SCOPES); +authUrl.searchParams.set("state", state); +authUrl.searchParams.set("code_challenge", challenge); +authUrl.searchParams.set("code_challenge_method", "S256"); + +console.log("\n[x-auth] Open this URL in your browser to authorize:\n"); +console.log(authUrl.toString()); +console.log(`\n[x-auth] Waiting for callback on http://127.0.0.1:${PORT}...\n`); + +const server = Bun.serve({ + port: PORT, + async fetch(req) { + const url = new URL(req.url); + if (url.pathname !== "/callback") { + return new Response("Not found", { + status: 404, + }); + } + + const code = url.searchParams.get("code"); + const returnedState = url.searchParams.get("state"); + + if (returnedState !== state) { + return new Response("State mismatch — possible CSRF. Try again.", { + status: 400, + }); + } + if (!code) { + const error = url.searchParams.get("error") ?? "unknown"; + return new Response(`Authorization denied: ${error}`, { + status: 400, + }); + } + + // Exchange code for tokens + const basicAuth = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64"); + const tokenRes = await fetch("https://api.x.com/2/oauth2/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${basicAuth}`, + }, + body: new URLSearchParams({ + code, + grant_type: "authorization_code", + redirect_uri: REDIRECT_URI, + code_verifier: verifier, + }), + }); + + if (!tokenRes.ok) { + const err = await tokenRes.text(); + console.error(`[x-auth] Token exchange failed: ${err}`); + return new Response(`Token exchange failed: ${err}`, { + status: 500, + }); + } + + const tokens: unknown = await tokenRes.json(); + const accessToken = (tokens as Record).access_token; + const refreshToken = (tokens as Record).refresh_token; + const expiresIn = (tokens as Record).expires_in; + + if (typeof accessToken !== "string" || typeof refreshToken !== "string") { + console.error("[x-auth] Missing tokens in response"); + return new Response("Missing tokens in response", { + status: 500, + }); + } + + const expiresAt = Date.now() + (typeof expiresIn === "number" ? expiresIn : 7200) * 1000; + + // Save to DB + const db = openTokenDb(); + db.run( + `INSERT INTO x_tokens (id, access_token, refresh_token, expires_at, updated_at) + VALUES (1, ?, ?, ?, ?) + ON CONFLICT (id) DO UPDATE SET + access_token = excluded.access_token, + refresh_token = excluded.refresh_token, + expires_at = excluded.expires_at, + updated_at = excluded.updated_at`, + [ + accessToken, + refreshToken, + expiresAt, + new Date().toISOString(), + ], + ); + db.close(); + + console.log("[x-auth] Tokens saved to state.db"); + console.log("[x-auth] Done — you can close this tab."); + + setTimeout(() => { + server.stop(); + process.exit(0); + }, 500); + + return new Response("

Authorized!

Tokens saved. You can close this tab.

", { + headers: { + "Content-Type": "text/html", + }, + }); + }, +}); diff --git a/.claude/skills/setup-agent-team/x-post.ts b/.claude/skills/setup-agent-team/x-post.ts index 79b24351..3cdd0f68 100644 --- a/.claude/skills/setup-agent-team/x-post.ts +++ b/.claude/skills/setup-agent-team/x-post.ts @@ -1,12 +1,10 @@ /** - * X (Twitter) Post — Post a tweet via X API v2. + * X (Twitter) Post — Post a tweet via X API v2 (OAuth 2.0). * - * Uses OAuth 1.0a to authenticate and POST /2/tweets. - * Can post standalone tweets or replies (pass in_reply_to_tweet_id). + * Reads tokens from state.db (written by x-auth.ts), auto-refreshes if expired. * * Usage: - * X_API_KEY=... X_API_SECRET=... X_ACCESS_TOKEN=... X_ACCESS_TOKEN_SECRET=... \ - * TWEET_TEXT="Hello world" bun run x-post.ts + * X_CLIENT_ID=... X_CLIENT_SECRET=... TWEET_TEXT="Hello world" bun run x-post.ts * * Optional env: * REPLY_TO_TWEET_ID — if set, the tweet is posted as a reply to this tweet ID @@ -14,18 +12,18 @@ * Outputs JSON: { "id": "...", "text": "..." } on success, exits 1 on failure. */ -import { createHmac, randomBytes } from "node:crypto"; +import { Database } from "bun:sqlite"; +import { existsSync } from "node:fs"; import * as v from "valibot"; -const API_KEY = process.env.X_API_KEY ?? ""; -const API_SECRET = process.env.X_API_SECRET ?? ""; -const ACCESS_TOKEN = process.env.X_ACCESS_TOKEN ?? ""; -const ACCESS_TOKEN_SECRET = process.env.X_ACCESS_TOKEN_SECRET ?? ""; +const CLIENT_ID = process.env.X_CLIENT_ID ?? ""; +const CLIENT_SECRET = process.env.X_CLIENT_SECRET ?? ""; const TWEET_TEXT = process.env.TWEET_TEXT ?? ""; const REPLY_TO = process.env.REPLY_TO_TWEET_ID ?? ""; +const DB_PATH = `${process.env.HOME ?? "/tmp"}/.config/spawn/state.db`; -if (!API_KEY || !API_SECRET || !ACCESS_TOKEN || !ACCESS_TOKEN_SECRET) { - console.error("[x-post] Missing X API credentials"); +if (!CLIENT_ID || !CLIENT_SECRET) { + console.error("[x-post] X_CLIENT_ID and X_CLIENT_SECRET are required"); process.exit(1); } @@ -46,53 +44,120 @@ const PostResponseSchema = v.object({ }), }); -const ErrorResponseSchema = v.object({ - detail: v.optional(v.string()), - title: v.optional(v.string()), - errors: v.optional( - v.array( - v.object({ - message: v.optional(v.string()), - }), - ), - ), +const TokenResponseSchema = v.object({ + access_token: v.string(), + refresh_token: v.optional(v.string()), + expires_in: v.optional(v.number()), }); -/** - * Generate OAuth 1.0a Authorization header for X API requests. - */ -function generateOAuthHeader(method: string, url: string, body?: string): string { - const oauthParams: Record = { - oauth_consumer_key: API_KEY, - oauth_nonce: randomBytes(16).toString("hex"), - oauth_signature_method: "HMAC-SHA1", - oauth_timestamp: String(Math.floor(Date.now() / 1000)), - oauth_token: ACCESS_TOKEN, - oauth_version: "1.0", +interface StoredTokens { + accessToken: string; + refreshToken: string; + expiresAt: number; +} + +function loadTokens(): StoredTokens | null { + if (!existsSync(DB_PATH)) return null; + try { + const db = new Database(DB_PATH, { + readonly: true, + }); + const row = db + .query< + { + access_token: string; + refresh_token: string; + expires_at: number; + }, + [] + >("SELECT access_token, refresh_token, expires_at FROM x_tokens WHERE id = 1") + .get(); + db.close(); + if (!row) return null; + return { + accessToken: row.access_token, + refreshToken: row.refresh_token, + expiresAt: row.expires_at, + }; + } catch { + return null; + } +} + +function saveTokens(tokens: StoredTokens): void { + const db = new Database(DB_PATH); + db.run( + `INSERT INTO x_tokens (id, access_token, refresh_token, expires_at, updated_at) + VALUES (1, ?, ?, ?, ?) + ON CONFLICT (id) DO UPDATE SET + access_token = excluded.access_token, + refresh_token = excluded.refresh_token, + expires_at = excluded.expires_at, + updated_at = excluded.updated_at`, + [ + tokens.accessToken, + tokens.refreshToken, + tokens.expiresAt, + new Date().toISOString(), + ], + ); + db.close(); +} + +async function refreshToken(currentRefresh: string): Promise { + const basicAuth = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64"); + const res = await fetch("https://api.x.com/2/oauth2/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${basicAuth}`, + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: currentRefresh, + }), + }); + + if (!res.ok) { + console.error(`[x-post] Token refresh failed: ${res.status} ${await res.text()}`); + return null; + } + + const json: unknown = await res.json(); + const parsed = v.safeParse(TokenResponseSchema, json); + if (!parsed.success) return null; + + const newTokens: StoredTokens = { + accessToken: parsed.output.access_token, + refreshToken: parsed.output.refresh_token ?? currentRefresh, + expiresAt: Date.now() + (parsed.output.expires_in ?? 7200) * 1000, }; + saveTokens(newTokens); + return newTokens; +} - // For POST with JSON body, only OAuth params go into the signature base - const allParams = { - ...oauthParams, - }; - const sortedKeys = Object.keys(allParams).sort(); - const paramString = sortedKeys.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(allParams[k])}`).join("&"); +async function getAccessToken(): Promise { + const tokens = loadTokens(); + if (!tokens) { + console.error("[x-post] No tokens in state.db — run x-auth.ts first"); + process.exit(1); + } - const signatureBase = `${method.toUpperCase()}&${encodeURIComponent(url)}&${encodeURIComponent(paramString)}`; - const signingKey = `${encodeURIComponent(API_SECRET)}&${encodeURIComponent(ACCESS_TOKEN_SECRET)}`; - const signature = createHmac("sha1", signingKey).update(signatureBase).digest("base64"); + if (Date.now() > tokens.expiresAt - 300_000) { + console.error("[x-post] Token expired, refreshing..."); + const refreshed = await refreshToken(tokens.refreshToken); + if (!refreshed) { + console.error("[x-post] Refresh failed — re-run x-auth.ts"); + process.exit(1); + } + return refreshed.accessToken; + } - oauthParams.oauth_signature = signature; - - const headerParts = Object.keys(oauthParams) - .sort() - .map((k) => `${encodeURIComponent(k)}="${encodeURIComponent(oauthParams[k])}"`) - .join(", "); - - return `OAuth ${headerParts}`; + return tokens.accessToken; } async function postTweet(): Promise { + const accessToken = await getAccessToken(); const url = "https://api.x.com/2/tweets"; const payload: Record = { @@ -104,27 +169,20 @@ async function postTweet(): Promise { }; } - const body = JSON.stringify(payload); - const authHeader = generateOAuthHeader("POST", url, body); - const res = await fetch(url, { method: "POST", headers: { - Authorization: authHeader, + Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "User-Agent": "spawn-growth/1.0", }, - body, + body: JSON.stringify(payload), }); const json: unknown = await res.json(); if (!res.ok) { - const err = v.safeParse(ErrorResponseSchema, json); - const detail = err.success - ? (err.output.detail ?? err.output.errors?.[0]?.message ?? `HTTP ${res.status}`) - : `HTTP ${res.status}`; - console.error(`[x-post] Failed: ${detail}`); + console.error(`[x-post] Failed: ${res.status} ${JSON.stringify(json).slice(0, 300)}`); process.exit(1); } diff --git a/.claude/skills/setup-spa/helpers.ts b/.claude/skills/setup-spa/helpers.ts index cd4a71e1..1ce745b8 100644 --- a/.claude/skills/setup-spa/helpers.ts +++ b/.claude/skills/setup-spa/helpers.ts @@ -203,6 +203,15 @@ export function openDb(path?: string): Database { created_at TEXT NOT NULL ) `); + db.run(` + CREATE TABLE IF NOT EXISTS x_tokens ( + id INTEGER PRIMARY KEY CHECK (id = 1), + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + expires_at INTEGER NOT NULL, + updated_at TEXT NOT NULL + ) + `); if (!path) { migrateFromJson(db); } diff --git a/.claude/skills/setup-spa/main.ts b/.claude/skills/setup-spa/main.ts index 625cbf75..d719c8e6 100644 --- a/.claude/skills/setup-spa/main.ts +++ b/.claude/skills/setup-spa/main.ts @@ -5,7 +5,7 @@ import type { ActionsBlock, ContextBlock, KnownBlock, SectionBlock } from "@slac import type { Block } from "@slack/types"; import type { ToolCall } from "./helpers"; -import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { timingSafeEqual } from "node:crypto"; import { isString, toRecord } from "@openrouter/spawn-shared"; import { App } from "@slack/bolt"; import * as v from "valibot"; @@ -51,10 +51,8 @@ const REDDIT_CLIENT_SECRET = process.env.REDDIT_CLIENT_SECRET ?? ""; const REDDIT_USERNAME = process.env.REDDIT_USERNAME ?? ""; const REDDIT_PASSWORD = process.env.REDDIT_PASSWORD ?? ""; const REDDIT_USER_AGENT = `spawn-growth:v1.0.0 (by /u/${REDDIT_USERNAME})`; -const X_API_KEY = process.env.X_API_KEY ?? ""; -const X_API_SECRET = process.env.X_API_SECRET ?? ""; -const X_ACCESS_TOKEN = process.env.X_ACCESS_TOKEN ?? ""; -const X_ACCESS_TOKEN_SECRET = process.env.X_ACCESS_TOKEN_SECRET ?? ""; +const X_CLIENT_ID = process.env.X_CLIENT_ID ?? ""; +const X_CLIENT_SECRET = process.env.X_CLIENT_SECRET ?? ""; for (const [name, value] of Object.entries({ SLACK_BOT_TOKEN, @@ -68,7 +66,7 @@ for (const [name, value] of Object.entries({ // #endregion -// #region X (Twitter) posting +// #region X (Twitter) posting — OAuth 2.0 with PKCE token refresh interface XPostResult { ok: boolean; @@ -77,34 +75,6 @@ interface XPostResult { error?: string; } -/** Generate OAuth 1.0a Authorization header for X API. */ -function generateXOAuthHeader(method: string, url: string): string { - const oauthParams: Record = { - oauth_consumer_key: X_API_KEY, - oauth_nonce: randomBytes(16).toString("hex"), - oauth_signature_method: "HMAC-SHA1", - oauth_timestamp: String(Math.floor(Date.now() / 1000)), - oauth_token: X_ACCESS_TOKEN, - oauth_version: "1.0", - }; - - const sortedKeys = Object.keys(oauthParams).sort(); - const paramString = sortedKeys.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(oauthParams[k])}`).join("&"); - - const signatureBase = `${method.toUpperCase()}&${encodeURIComponent(url)}&${encodeURIComponent(paramString)}`; - const signingKey = `${encodeURIComponent(X_API_SECRET)}&${encodeURIComponent(X_ACCESS_TOKEN_SECRET)}`; - const signature = createHmac("sha1", signingKey).update(signatureBase).digest("base64"); - - oauthParams.oauth_signature = signature; - - const headerParts = Object.keys(oauthParams) - .sort() - .map((k) => `${encodeURIComponent(k)}="${encodeURIComponent(oauthParams[k])}"`) - .join(", "); - - return `OAuth ${headerParts}`; -} - const XPostResponseSchema = v.object({ data: v.object({ id: v.string(), @@ -112,12 +82,117 @@ const XPostResponseSchema = v.object({ }), }); -/** Post a tweet (or reply) to X. Returns result with tweet URL on success. */ +const XTokenResponseSchema = v.object({ + access_token: v.string(), + refresh_token: v.optional(v.string()), + expires_in: v.optional(v.number()), +}); + +interface StoredTokens { + accessToken: string; + refreshToken: string; + expiresAt: number; +} + +/** Load X OAuth 2.0 tokens from state.db. */ +function loadXTokens(): StoredTokens | null { + try { + const row = db + .query< + { + access_token: string; + refresh_token: string; + expires_at: number; + }, + [] + >("SELECT access_token, refresh_token, expires_at FROM x_tokens WHERE id = 1") + .get(); + if (!row) return null; + return { + accessToken: row.access_token, + refreshToken: row.refresh_token, + expiresAt: row.expires_at, + }; + } catch { + return null; + } +} + +/** Save refreshed tokens back to state.db. */ +function saveXTokens(tokens: StoredTokens): void { + db.run( + `INSERT INTO x_tokens (id, access_token, refresh_token, expires_at, updated_at) + VALUES (1, ?, ?, ?, ?) + ON CONFLICT (id) DO UPDATE SET + access_token = excluded.access_token, + refresh_token = excluded.refresh_token, + expires_at = excluded.expires_at, + updated_at = excluded.updated_at`, + [ + tokens.accessToken, + tokens.refreshToken, + tokens.expiresAt, + new Date().toISOString(), + ], + ); +} + +/** Refresh the X OAuth 2.0 access token using the refresh token. */ +async function refreshXToken(refreshToken: string): Promise { + if (!X_CLIENT_ID || !X_CLIENT_SECRET) return null; + + const basicAuth = Buffer.from(`${X_CLIENT_ID}:${X_CLIENT_SECRET}`).toString("base64"); + const res = await fetch("https://api.x.com/2/oauth2/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${basicAuth}`, + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + }), + }); + + if (!res.ok) { + console.error(`[x-post] Token refresh failed: ${res.status}`); + return null; + } + + const json: unknown = await res.json(); + const parsed = v.safeParse(XTokenResponseSchema, json); + if (!parsed.success) return null; + + const newTokens: StoredTokens = { + accessToken: parsed.output.access_token, + refreshToken: parsed.output.refresh_token ?? refreshToken, + expiresAt: Date.now() + (parsed.output.expires_in ?? 7200) * 1000, + }; + saveXTokens(newTokens); + return newTokens; +} + +/** Get a valid X access token, refreshing if expired. */ +async function getXAccessToken(): Promise { + const tokens = loadXTokens(); + if (!tokens) return null; + + // Refresh if expires within 5 minutes + if (Date.now() > tokens.expiresAt - 300_000) { + const refreshed = await refreshXToken(tokens.refreshToken); + return refreshed?.accessToken ?? null; + } + + return tokens.accessToken; +} + +/** Post a tweet (or reply) to X using OAuth 2.0 Bearer token. */ async function postToX(text: string, replyToTweetId?: string): Promise { - if (!X_API_KEY || !X_API_SECRET || !X_ACCESS_TOKEN || !X_ACCESS_TOKEN_SECRET) { + const accessToken = await getXAccessToken(); + if (!accessToken) { return { ok: false, - error: "X API credentials not configured", + error: "No X OAuth 2.0 tokens — run x-auth.ts to authorize", }; } if (!text || text.length > 280) { @@ -137,13 +212,11 @@ async function postToX(text: string, replyToTweetId?: string): Promise