feat(growth): migrate X posting from OAuth 1.0a to OAuth 2.0 PKCE (#3329)
Some checks are pending
Lint / ShellCheck (push) Waiting to run
Lint / Biome Lint (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run

- 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 <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
A 2026-04-20 00:35:04 -07:00 committed by GitHub
parent 95da999efb
commit 2306fb1914
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 415 additions and 100 deletions

View file

@ -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<string, unknown>).access_token;
const refreshToken = (tokens as Record<string, unknown>).refresh_token;
const expiresIn = (tokens as Record<string, unknown>).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("<html><body><h1>Authorized!</h1><p>Tokens saved. You can close this tab.</p></body></html>", {
headers: {
"Content-Type": "text/html",
},
});
},
});

View file

@ -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<string, string> = {
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<StoredTokens | null> {
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<string> {
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<void> {
const accessToken = await getAccessToken();
const url = "https://api.x.com/2/tweets";
const payload: Record<string, unknown> = {
@ -104,27 +169,20 @@ async function postTweet(): Promise<void> {
};
}
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);
}

View file

@ -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);
}

View file

@ -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<string, string> = {
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<StoredTokens | null> {
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<string | null> {
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<XPostResult> {
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<XPostResu
};
}
const authHeader = generateXOAuthHeader("POST", url);
try {
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: authHeader,
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": "spawn-growth/1.0",
},