From 13c1be40971da11d56aef19fc50176146cde45d4 Mon Sep 17 00:00:00 2001 From: Vivek Kotecha Date: Wed, 31 Dec 2025 17:32:09 +0530 Subject: [PATCH 1/3] feat: add auth modes + OAuth2 PKCE --- .gitignore | 7 +- README.md | 70 +++-- package.json | 48 +++- src/__tests__/README.md | 6 +- src/__tests__/TESTING_GUIDE.md | 22 +- src/__tests__/e2e/twitter-integration.test.ts | 2 - src/__tests__/environment.test.ts | 50 +++- src/base.ts | 77 ++---- src/client/__tests__/auth.test.ts | 31 +-- src/client/__tests__/broker-provider.test.ts | 16 ++ src/client/__tests__/oauth2-provider.test.ts | 202 +++++++++++++++ src/client/__tests__/pkce.test.ts | 19 ++ src/client/__tests__/token-store.test.ts | 72 +++++ src/client/auth-providers/broker.ts | 33 +++ src/client/auth-providers/env.ts | 59 +++++ src/client/auth-providers/factory.ts | 34 +++ src/client/auth-providers/interactive.ts | 119 +++++++++ src/client/auth-providers/oauth2-pkce.ts | 245 ++++++++++++++++++ src/client/auth-providers/pkce.ts | 24 ++ src/client/auth-providers/token-store.ts | 93 +++++++ src/client/auth-providers/types.ts | 41 +++ src/client/auth.ts | 67 +++-- src/client/client.ts | 23 +- src/client/profile.ts | 4 +- src/client/relationships.ts | 10 +- src/client/search.ts | 4 +- src/client/tweets.ts | 26 +- src/environment.ts | 96 +++++-- src/index.ts | 70 +++-- src/services/MessageService.ts | 36 ++- src/services/PostService.ts | 44 +++- tsup.config.ts | 9 + 32 files changed, 1466 insertions(+), 193 deletions(-) create mode 100644 src/client/__tests__/broker-provider.test.ts create mode 100644 src/client/__tests__/oauth2-provider.test.ts create mode 100644 src/client/__tests__/pkce.test.ts create mode 100644 src/client/__tests__/token-store.test.ts create mode 100644 src/client/auth-providers/broker.ts create mode 100644 src/client/auth-providers/env.ts create mode 100644 src/client/auth-providers/factory.ts create mode 100644 src/client/auth-providers/interactive.ts create mode 100644 src/client/auth-providers/oauth2-pkce.ts create mode 100644 src/client/auth-providers/pkce.ts create mode 100644 src/client/auth-providers/token-store.ts create mode 100644 src/client/auth-providers/types.ts diff --git a/.gitignore b/.gitignore index 79b14e1..3f0a64c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,9 @@ node_modules dist .turbo .env.test -coverage \ No newline at end of file +coverage + +# Defense-in-depth: ignore potential local OAuth token persistence files +oauth2.tokens.json +*.oauth2.tokens.json +.twitter-oauth* \ No newline at end of file diff --git a/README.md b/README.md index 5adba9b..d897727 100644 --- a/README.md +++ b/README.md @@ -8,21 +8,36 @@ This package provides Twitter/X integration for the Eliza AI agent using the off 1. **Get Twitter Developer account** → https://developer.twitter.com 2. **Create an app** → Enable "Read and write" permissions -3. **Get OAuth 1.0a credentials** (NOT OAuth 2.0!): - - API Key & Secret (from "Consumer Keys") - - Access Token & Secret (from "Authentication Tokens") +3. Choose your auth mode: + + - **Option A (default, legacy): OAuth 1.0a env vars** + - API Key & Secret (from "Consumer Keys") + - Access Token & Secret (from "Authentication Tokens") + + - **Option B (recommended): “login + approve” OAuth 2.0 (PKCE)** + - Client ID (from "OAuth 2.0 Client ID") + - Redirect URI (loopback recommended) + 4. **Add to `.env`:** ```bash + # Option A: legacy OAuth 1.0a (default) + TWITTER_AUTH_MODE=env TWITTER_API_KEY=xxx TWITTER_API_SECRET_KEY=xxx TWITTER_ACCESS_TOKEN=xxx TWITTER_ACCESS_TOKEN_SECRET=xxx + + # Option B: OAuth 2.0 PKCE (interactive login + approve, no client secret) + # TWITTER_AUTH_MODE=oauth + # TWITTER_CLIENT_ID=xxx + # TWITTER_REDIRECT_URI=http://127.0.0.1:8080/callback + TWITTER_ENABLE_POST=true TWITTER_POST_IMMEDIATELY=true ``` 5. **Run:** `bun start` -⚠️ **Common mistake:** Using OAuth 2.0 credentials instead of OAuth 1.0a - see [Step 3](#step-3-get-the-right-credentials-oauth-10a) for details! +Tip: if you use **OAuth 2.0 PKCE**, the plugin will print an authorization URL on first run and store tokens for you (no manual token pasting). ## Features @@ -39,7 +54,7 @@ This package provides Twitter/X integration for the Eliza AI agent using the off ## Prerequisites - Twitter Developer Account with API v2 access -- Twitter OAuth 1.0a credentials (NOT OAuth 2.0) +- Either Twitter OAuth 1.0a credentials (legacy env vars) or OAuth 2.0 Client ID (PKCE) - Node.js and bun installed ## 🚀 Quick Start @@ -77,12 +92,12 @@ This package provides Twitter/X integration for the Eliza AI agent using the off ### Step 3: Get the RIGHT Credentials (OAuth 1.0a) -**⚠️ IMPORTANT: You need OAuth 1.0a credentials, NOT OAuth 2.0!** +You can use either legacy **OAuth 1.0a** env vars (default) or **OAuth 2.0 PKCE** (“login + approve”). In your app's **"Keys and tokens"** page, you'll see several sections. Here's what to use: ``` -✅ USE THESE (OAuth 1.0a): +✅ USE THESE when TWITTER_AUTH_MODE=env (OAuth 1.0a): ┌─────────────────────────────────────────────────┐ │ Consumer Keys │ │ ├─ API Key: xxx...xxx → TWITTER_API_KEY │ @@ -93,13 +108,13 @@ In your app's **"Keys and tokens"** page, you'll see several sections. Here's wh │ └─ Access Token Secret: xxx → TWITTER_ACCESS_TOKEN_SECRET │ └─────────────────────────────────────────────────┘ -❌ DO NOT USE THESE (OAuth 2.0): +✅ USE THESE when TWITTER_AUTH_MODE=oauth (OAuth 2.0 PKCE): ┌─────────────────────────────────────────────────┐ │ OAuth 2.0 Client ID and Client Secret │ -│ ├─ Client ID: xxx...xxx ← IGNORE │ -│ └─ Client Secret: xxx...xxx ← IGNORE │ +│ ├─ Client ID: xxx...xxx → TWITTER_CLIENT_ID │ +│ └─ Client Secret: xxx...xxx ← NOT USED (do not put in env) │ │ │ -│ Bearer Token ← IGNORE │ +│ Bearer Token ← NOT USED │ └─────────────────────────────────────────────────┘ ``` @@ -113,6 +128,12 @@ In your app's **"Keys and tokens"** page, you'll see several sections. Here's wh Create or edit `.env` file in your project root: ```bash +# Auth mode (default: env) +# - env: legacy OAuth 1.0a keys/tokens +# - oauth: “login + approve” OAuth 2.0 PKCE (no client secret in plugin) +# - broker: stub (not implemented yet) +TWITTER_AUTH_MODE=env + # REQUIRED: OAuth 1.0a Credentials (from "Consumer Keys" section) TWITTER_API_KEY=your_api_key_here # From "API Key" TWITTER_API_SECRET_KEY=your_api_key_secret_here # From "API Key Secret" @@ -121,6 +142,14 @@ TWITTER_API_SECRET_KEY=your_api_key_secret_here # From "API Key Secret" TWITTER_ACCESS_TOKEN=your_access_token_here # Must have "Read and Write" TWITTER_ACCESS_TOKEN_SECRET=your_token_secret_here # Regenerate after permission change +# ---- OR ---- +# OAuth 2.0 PKCE (“login + approve”) configuration: +# TWITTER_AUTH_MODE=oauth +# TWITTER_CLIENT_ID=your_oauth2_client_id_here +# TWITTER_REDIRECT_URI=http://127.0.0.1:8080/callback +# Optional: +# TWITTER_SCOPES="tweet.read tweet.write users.read offline.access" + # Basic Configuration TWITTER_DRY_RUN=false # Set to true to test without posting TWITTER_ENABLE_POST=true # Enable autonomous tweet posting @@ -133,6 +162,11 @@ TWITTER_POST_INTERVAL_MIN=90 # Minimum minutes between posts TWITTER_POST_INTERVAL_MAX=150 # Maximum minutes between posts ``` +When using **TWITTER_AUTH_MODE=oauth**, the plugin will: +- Print an authorization URL on first run +- Capture the callback via a local loopback server **or** ask you to paste the redirected URL +- Persist tokens via Eliza runtime cache if available, otherwise a local token file at `~/.eliza/twitter/oauth2.tokens.json` + ### Step 5: Run Your Bot ```typescript @@ -348,12 +382,17 @@ This is the #1 issue! Your app has read-only permissions. ### "Could not authenticate you" -Wrong credentials or using OAuth 2.0 instead of OAuth 1.0a. +This usually means your credentials don’t match your selected auth mode. **Solution:** -- Use credentials from "Consumer Keys" section (API Key/Secret) -- Use credentials from "Authentication Tokens" section (Access Token/Secret) -- Do NOT use OAuth 2.0 Client ID, Client Secret, or Bearer Token +- If `TWITTER_AUTH_MODE=env`: + - Use credentials from "Consumer Keys" section (API Key/Secret) + - Use credentials from "Authentication Tokens" section (Access Token/Secret) + - Do not use OAuth 2.0 Client ID/Client Secret/Bearer Token for this mode +- If `TWITTER_AUTH_MODE=oauth`: + - Use OAuth 2.0 **Client ID** (`TWITTER_CLIENT_ID`) + - Set a loopback redirect URI (`TWITTER_REDIRECT_URI`, e.g. `http://127.0.0.1:8080/callback`) + - Do not set/ship a client secret (PKCE flow) ### Bot Not Posting Automatically @@ -460,6 +499,7 @@ Monitor your usage at: https://developer.twitter.com/en/portal/dashboard - [Twitter API v2 Documentation](https://developer.twitter.com/en/docs/twitter-api) - [Twitter OAuth 1.0a Guide](https://developer.twitter.com/en/docs/authentication/oauth-1-0a) +- [Twitter OAuth 2.0 (Authorization Code with PKCE)](https://developer.twitter.com/en/docs/authentication/oauth-2-0/authorization-code) - [Rate Limits Reference](https://developer.twitter.com/en/docs/twitter-api/rate-limits) - [ElizaOS Documentation](https://github.com/elizaos/eliza) diff --git a/package.json b/package.json index 137c81b..a0a57a7 100644 --- a/package.json +++ b/package.json @@ -52,30 +52,62 @@ "agentConfig": { "pluginType": "elizaos:plugin:1.0.0", "pluginParameters": { + "TWITTER_AUTH_MODE": { + "type": "string", + "description": "Auth mode: 'env' (legacy keys/tokens), 'oauth' (3-legged OAuth2 PKCE), or 'broker' (stub).", + "required": false, + "default": "env", + "sensitive": false + }, "TWITTER_API_KEY": { "type": "string", - "description": "Twitter API v2 key for authentication.", - "required": true, + "description": "Twitter API key (required for TWITTER_AUTH_MODE=env).", + "required": false, "sensitive": true }, "TWITTER_API_SECRET_KEY": { "type": "string", - "description": "Twitter API v2 secret key paired with the API key for authentication.", - "required": true, + "description": "Twitter API secret key (required for TWITTER_AUTH_MODE=env).", + "required": false, "sensitive": true }, "TWITTER_ACCESS_TOKEN": { "type": "string", - "description": "OAuth access token for Twitter API v2 requests.", - "required": true, + "description": "OAuth1.0a access token (required for TWITTER_AUTH_MODE=env).", + "required": false, "sensitive": true }, "TWITTER_ACCESS_TOKEN_SECRET": { "type": "string", - "description": "OAuth access token secret used with the access token for authentication.", - "required": true, + "description": "OAuth1.0a access token secret (required for TWITTER_AUTH_MODE=env).", + "required": false, "sensitive": true }, + "TWITTER_CLIENT_ID": { + "type": "string", + "description": "Twitter OAuth2 client ID (required for TWITTER_AUTH_MODE=oauth).", + "required": false, + "sensitive": false + }, + "TWITTER_REDIRECT_URI": { + "type": "string", + "description": "OAuth2 redirect URI (loopback recommended) (required for TWITTER_AUTH_MODE=oauth).", + "required": false, + "sensitive": false + }, + "TWITTER_SCOPES": { + "type": "string", + "description": "OAuth2 scopes (space-separated).", + "required": false, + "default": "tweet.read tweet.write users.read offline.access", + "sensitive": false + }, + "TWITTER_BROKER_URL": { + "type": "string", + "description": "Broker URL (required for TWITTER_AUTH_MODE=broker; stub only).", + "required": false, + "sensitive": false + }, "TWITTER_TARGET_USERS": { "type": "string", "description": "Comma-separated list of Twitter usernames the bot should interact with. Use '*' for all users.", diff --git a/src/__tests__/README.md b/src/__tests__/README.md index 490d3df..eed38a2 100644 --- a/src/__tests__/README.md +++ b/src/__tests__/README.md @@ -34,12 +34,13 @@ npm test -- --coverage ### End-to-End Tests -E2E tests require real Twitter Developer API credentials. +E2E tests require real Twitter Developer API credentials and currently exercise **TWITTER_AUTH_MODE=env** (OAuth 1.0a keys/tokens). +The plugin also supports **TWITTER_AUTH_MODE=oauth** (OAuth 2.0 PKCE “login + approve”), but that flow is interactive and is not covered by these E2E tests. #### Prerequisites 1. **Twitter Developer Account**: You need a Twitter Developer account with an app created -2. **API Credentials**: You need all four credentials: +2. **API Credentials (env mode)**: You need all four credentials: - API Key (Consumer Key) - API Secret Key (Consumer Secret) - Access Token @@ -51,6 +52,7 @@ E2E tests require real Twitter Developer API credentials. ```env # Twitter API v2 Credentials +TWITTER_AUTH_MODE=env TWITTER_API_KEY=your_api_key_here TWITTER_API_SECRET_KEY=your_api_secret_key_here TWITTER_ACCESS_TOKEN=your_access_token_here diff --git a/src/__tests__/TESTING_GUIDE.md b/src/__tests__/TESTING_GUIDE.md index acd5537..32bb88d 100644 --- a/src/__tests__/TESTING_GUIDE.md +++ b/src/__tests__/TESTING_GUIDE.md @@ -2,18 +2,27 @@ ## Overview -This guide explains how to test the refactored Twitter plugin after removing username/password authentication and Twitter Spaces functionality. The plugin now exclusively uses Twitter API v2 with developer credentials. +This guide explains how to test the Twitter plugin after removing username/password authentication and Twitter Spaces functionality. + +The plugin supports multiple auth modes: +- `TWITTER_AUTH_MODE=env` (legacy OAuth 1.0a keys/tokens) +- `TWITTER_AUTH_MODE=oauth` (OAuth 2.0 Authorization Code + PKCE, interactive “login + approve”, no client secret) +- `TWITTER_AUTH_MODE=broker` (stub only, not implemented yet) ## Prerequisites ### 1. Twitter Developer Account -You need a Twitter Developer account with: +You need a Twitter Developer account. Which credentials you need depends on auth mode: -- API Key -- API Secret Key -- Access Token -- Access Token Secret +- For `TWITTER_AUTH_MODE=env` (E2E tests use this): + - API Key + - API Secret Key + - Access Token + - Access Token Secret +- For `TWITTER_AUTH_MODE=oauth`: + - OAuth 2.0 Client ID (`TWITTER_CLIENT_ID`) + - Redirect URI (`TWITTER_REDIRECT_URI`) To get these credentials: @@ -27,6 +36,7 @@ To get these credentials: Create a `.env.test` file in the plugin root directory: ```bash +TWITTER_AUTH_MODE=env TWITTER_API_KEY=your_api_key_here TWITTER_API_SECRET_KEY=your_api_secret_key_here TWITTER_ACCESS_TOKEN=your_access_token_here diff --git a/src/__tests__/e2e/twitter-integration.test.ts b/src/__tests__/e2e/twitter-integration.test.ts index 0c4e0ad..0478127 100644 --- a/src/__tests__/e2e/twitter-integration.test.ts +++ b/src/__tests__/e2e/twitter-integration.test.ts @@ -7,7 +7,6 @@ import { beforeEach, vi, } from "vitest"; -import { TwitterAuth } from "../../client/auth"; import { TwitterMessageService } from "../../services/MessageService"; import { TwitterPostService } from "../../services/PostService"; import { ClientBase } from "../../base"; @@ -27,7 +26,6 @@ const SKIP_E2E = !process.env.TWITTER_ACCESS_TOKEN_SECRET; describe.skipIf(SKIP_E2E)("Twitter E2E Integration Tests", () => { - let auth: TwitterAuth; let client: ClientBase; let messageService: TwitterMessageService; let postService: TwitterPostService; diff --git a/src/__tests__/environment.test.ts b/src/__tests__/environment.test.ts index eccd04b..c057131 100644 --- a/src/__tests__/environment.test.ts +++ b/src/__tests__/environment.test.ts @@ -22,6 +22,10 @@ describe("Environment Configuration", () => { vi.stubEnv("TWITTER_API_SECRET_KEY", ""); vi.stubEnv("TWITTER_ACCESS_TOKEN", ""); vi.stubEnv("TWITTER_ACCESS_TOKEN_SECRET", ""); + vi.stubEnv("TWITTER_AUTH_MODE", ""); + vi.stubEnv("TWITTER_CLIENT_ID", ""); + vi.stubEnv("TWITTER_REDIRECT_URI", ""); + vi.stubEnv("TWITTER_BROKER_URL", ""); }); describe("shouldTargetUser", () => { @@ -87,7 +91,51 @@ describe("Environment Configuration", () => { mockRuntime.getSetting = vi.fn(() => undefined); await expect(validateTwitterConfig(mockRuntime)).rejects.toThrow( - "Twitter API credentials are required", + "Twitter env auth is selected", + ); + }); + + it("should validate oauth mode without legacy env credentials", async () => { + mockRuntime.getSetting = vi.fn((key) => { + const settings: Record = { + TWITTER_AUTH_MODE: "oauth", + TWITTER_CLIENT_ID: "client-id", + TWITTER_REDIRECT_URI: "http://127.0.0.1:8080/callback", + }; + return settings[key]; + }); + + const config = await validateTwitterConfig(mockRuntime); + expect(config.TWITTER_AUTH_MODE).toBe("oauth"); + expect(config.TWITTER_CLIENT_ID).toBe("client-id"); + expect(config.TWITTER_REDIRECT_URI).toBe("http://127.0.0.1:8080/callback"); + }); + + it("should throw when oauth mode is missing required fields", async () => { + mockRuntime.getSetting = vi.fn((key) => { + const settings: Record = { + TWITTER_AUTH_MODE: "oauth", + TWITTER_CLIENT_ID: "client-id", + // missing redirect uri + }; + return settings[key]; + }); + + await expect(validateTwitterConfig(mockRuntime)).rejects.toThrow( + "Twitter OAuth is selected", + ); + }); + + it("should throw when broker mode is missing broker url", async () => { + mockRuntime.getSetting = vi.fn((key) => { + const settings: Record = { + TWITTER_AUTH_MODE: "broker", + }; + return settings[key]; + }); + + await expect(validateTwitterConfig(mockRuntime)).rejects.toThrow( + "Twitter broker auth is selected", ); }); diff --git a/src/base.ts b/src/base.ts index c95f3d3..dd87d31 100644 --- a/src/base.ts +++ b/src/base.ts @@ -17,6 +17,11 @@ import { import { TwitterInteractionPayload } from "./types"; import { getEpochMs } from "./utils/time"; import { createMemorySafe } from "./utils/memory"; +import { getSetting } from "./utils/settings"; +import { + createTwitterAuthProvider, + getTwitterAuthMode, +} from "./client/auth-providers/factory"; interface TwitterUser { id_str: string; @@ -253,19 +258,21 @@ export class ClientBase { this.runtime = runtime; this.state = state; - // Use API key as the identifier for client reuse - const apiKey = - state?.TWITTER_API_KEY || - (runtime && typeof runtime.getSetting === "function" - ? runtime.getSetting("TWITTER_API_KEY") - : null) || - process.env.TWITTER_API_KEY; - if (apiKey && ClientBase._twitterClients[apiKey]) { - this.twitterClient = ClientBase._twitterClients[apiKey]; + // Use a stable identifier for client reuse per auth mode. + const mode = getTwitterAuthMode(runtime, state); + const reuseKey = + mode === "env" + ? state?.TWITTER_API_KEY ?? getSetting(runtime, "TWITTER_API_KEY") + : mode === "oauth" + ? state?.TWITTER_CLIENT_ID ?? getSetting(runtime, "TWITTER_CLIENT_ID") + : state?.TWITTER_BROKER_URL ?? getSetting(runtime, "TWITTER_BROKER_URL"); + + if (reuseKey && ClientBase._twitterClients[reuseKey]) { + this.twitterClient = ClientBase._twitterClients[reuseKey]; } else { this.twitterClient = new Client(); - if (apiKey) { - ClientBase._twitterClients[apiKey] = this.twitterClient; + if (reuseKey) { + ClientBase._twitterClients[reuseKey] = this.twitterClient; } } } @@ -274,42 +281,7 @@ export class ClientBase { // First ensure the agent exists in the database // await this.runtime.ensureAgentExists(this.runtime.character); - const apiKey = - this.state?.TWITTER_API_KEY || - (this.runtime && typeof this.runtime.getSetting === "function" - ? this.runtime.getSetting("TWITTER_API_KEY") - : null) || - process.env.TWITTER_API_KEY; - const apiSecretKey = - this.state?.TWITTER_API_SECRET_KEY || - (this.runtime && typeof this.runtime.getSetting === "function" - ? this.runtime.getSetting("TWITTER_API_SECRET_KEY") - : null) || - process.env.TWITTER_API_SECRET_KEY; - const accessToken = - this.state?.TWITTER_ACCESS_TOKEN || - (this.runtime && typeof this.runtime.getSetting === "function" - ? this.runtime.getSetting("TWITTER_ACCESS_TOKEN") - : null) || - process.env.TWITTER_ACCESS_TOKEN; - const accessTokenSecret = - this.state?.TWITTER_ACCESS_TOKEN_SECRET || - (this.runtime && typeof this.runtime.getSetting === "function" - ? this.runtime.getSetting("TWITTER_ACCESS_TOKEN_SECRET") - : null) || - process.env.TWITTER_ACCESS_TOKEN_SECRET; - - // Validate required credentials - if (!apiKey || !apiSecretKey || !accessToken || !accessTokenSecret) { - const missing = []; - if (!apiKey) missing.push("TWITTER_API_KEY"); - if (!apiSecretKey) missing.push("TWITTER_API_SECRET_KEY"); - if (!accessToken) missing.push("TWITTER_ACCESS_TOKEN"); - if (!accessTokenSecret) missing.push("TWITTER_ACCESS_TOKEN_SECRET"); - throw new Error( - `Missing required Twitter API credentials: ${missing.join(", ")}`, - ); - } + const provider = createTwitterAuthProvider(this.runtime, this.state); const maxRetries = process.env.MAX_RETRIES ? parseInt(process.env.MAX_RETRIES) @@ -320,16 +292,7 @@ export class ClientBase { while (retryCount < maxRetries) { try { logger.log("Initializing Twitter API v2 client"); - await this.twitterClient.login( - "", // username not needed for API v2 - "", // password not needed for API v2 - "", // email not needed for API v2 - "", // 2FA not needed for API v2 - apiKey, - apiSecretKey, - accessToken, - accessTokenSecret, - ); + await this.twitterClient.authenticate(provider); if (await this.twitterClient.isLoggedIn()) { logger.info("Successfully authenticated with Twitter API v2"); diff --git a/src/client/__tests__/auth.test.ts b/src/client/__tests__/auth.test.ts index f06fefc..67b02db 100644 --- a/src/client/__tests__/auth.test.ts +++ b/src/client/__tests__/auth.test.ts @@ -26,29 +26,30 @@ describe("TwitterAuth", () => { (TwitterApi as any).mockImplementation(() => mockTwitterApi); - auth = new TwitterAuth( - "test-api-key", - "test-api-secret", - "test-access-token", - "test-access-secret", - ); - }); - - describe("constructor", () => { - it("should initialize with API credentials", () => { - expect(TwitterApi).toHaveBeenCalledWith({ + auth = new TwitterAuth({ + mode: "env", + getAccessToken: async () => "test-access-token", + getOAuth1Credentials: async () => ({ appKey: "test-api-key", appSecret: "test-api-secret", accessToken: "test-access-token", accessSecret: "test-access-secret", - }); + }), + } as any); + }); + + describe("constructor", () => { + it("should initialize with API credentials", () => { + // Initialization happens lazily on first use. + expect(TwitterApi).not.toHaveBeenCalled(); }); }); describe("getV2Client", () => { it("should return the Twitter API v2 client", () => { - const client = auth.getV2Client(); - expect(client).toBe(mockTwitterApi); + return auth.getV2Client().then((client) => { + expect(client).toBe(mockTwitterApi); + }); }); }); @@ -203,7 +204,7 @@ describe("TwitterAuth", () => { await auth.logout(); // Try to get client after logout - expect(() => auth.getV2Client()).toThrow( + await expect(auth.getV2Client()).rejects.toThrow( "Twitter API client not initialized", ); diff --git a/src/client/__tests__/broker-provider.test.ts b/src/client/__tests__/broker-provider.test.ts new file mode 100644 index 0000000..9b1121a --- /dev/null +++ b/src/client/__tests__/broker-provider.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect, vi } from "vitest"; +import { BrokerAuthProvider } from "../auth-providers/broker"; + +describe("BrokerAuthProvider", () => { + it("throws if TWITTER_BROKER_URL is missing", async () => { + const runtime: any = { + getSetting: vi.fn(() => undefined), + }; + + const provider = new BrokerAuthProvider(runtime); + await expect(provider.getAccessToken()).rejects.toThrow( + "TWITTER_AUTH_MODE=broker requires TWITTER_BROKER_URL", + ); + }); +}); + diff --git a/src/client/__tests__/oauth2-provider.test.ts b/src/client/__tests__/oauth2-provider.test.ts new file mode 100644 index 0000000..940dc3d --- /dev/null +++ b/src/client/__tests__/oauth2-provider.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { OAuth2PKCEAuthProvider } from "../auth-providers/oauth2-pkce"; +import type { TokenStore, StoredOAuth2Tokens } from "../auth-providers/token-store"; + +describe("OAuth2PKCEAuthProvider", () => { + let runtime: any; + + beforeEach(() => { + runtime = { + agentId: "agent-1", + getSetting: vi.fn((k: string) => { + const settings: Record = { + TWITTER_AUTH_MODE: "oauth", + TWITTER_CLIENT_ID: "client-id", + TWITTER_REDIRECT_URI: "http://127.0.0.1:8080/callback", + }; + return settings[k]; + }), + getCache: vi.fn(), + setCache: vi.fn(), + }; + }); + + it("returns existing non-expired access token without refresh", async () => { + const store: TokenStore = { + load: vi.fn(async () => ({ + access_token: "access", + refresh_token: "refresh", + expires_at: Date.now() + 60_000, + })), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + + const fetchImpl = vi.fn(); + const provider = new OAuth2PKCEAuthProvider(runtime, store, fetchImpl as any); + + const token = await provider.getAccessToken(); + expect(token).toBe("access"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("refreshes when expired and refresh_token is present", async () => { + const expired: StoredOAuth2Tokens = { + access_token: "old", + refresh_token: "refresh", + expires_at: Date.now() - 1, + }; + + const store: TokenStore = { + load: vi.fn(async () => expired), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + + const fetchImpl = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new-access", + refresh_token: "new-refresh", + expires_in: 3600, + token_type: "bearer", + scope: "tweet.read", + }), + })); + + const provider = new OAuth2PKCEAuthProvider(runtime, store, fetchImpl as any); + + const token = await provider.getAccessToken(); + expect(token).toBe("new-access"); + expect(store.save).toHaveBeenCalled(); + }); + + it("throws clear error on refresh failure", async () => { + const expired: StoredOAuth2Tokens = { + access_token: "old", + refresh_token: "refresh", + expires_at: Date.now() - 1, + }; + + const store: TokenStore = { + load: vi.fn(async () => expired), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + + const fetchImpl = vi.fn(async () => ({ + ok: false, + status: 400, + json: async () => ({ error: "invalid_grant" }), + })); + + const provider = new OAuth2PKCEAuthProvider(runtime, store, fetchImpl as any); + + await expect(provider.getAccessToken()).rejects.toThrow( + "Twitter token refresh failed", + ); + }); + + it("includes status/body on exchange failure", async () => { + const store: TokenStore = { + load: vi.fn(async () => null), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + + const fetchImpl = vi.fn(async () => ({ + ok: false, + status: 401, + json: async () => ({ error: "unauthorized_client" }), + })); + + const provider = new OAuth2PKCEAuthProvider( + runtime, + store, + fetchImpl as any, + // stub interactive login to call the real token exchange path by returning a failure via fetch + async () => { + // simulate what interactiveLogin would do: return tokens after exchange; + // here we force a call to the exchange endpoint by invoking getAccessToken without stored tokens. + // We can't access private methods, so we just throw an error consistent with exchange failure. + const res: any = await (fetchImpl as any)("https://api.twitter.com/2/oauth2/token", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "grant_type=authorization_code", + }); + const body = await res.json(); + throw new Error(`Twitter token exchange failed (${res.status}): ${JSON.stringify(body)}`); + }, + ); + + await expect(provider.getAccessToken()).rejects.toThrow( + 'Twitter token exchange failed (401): {"error":"unauthorized_client"}', + ); + }); + + it("refresh rotates refresh_token when returned", async () => { + const expired: StoredOAuth2Tokens = { + access_token: "old", + refresh_token: "refresh-old", + expires_at: Date.now() - 1, + }; + + const store: TokenStore = { + load: vi.fn(async () => expired), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + + const fetchImpl = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new-access", + refresh_token: "refresh-new", + expires_in: 3600, + }), + })); + + const provider = new OAuth2PKCEAuthProvider(runtime, store, fetchImpl as any); + const token = await provider.getAccessToken(); + expect(token).toBe("new-access"); + + // ensure we persisted rotated refresh token + expect(store.save).toHaveBeenCalledWith( + expect.objectContaining({ refresh_token: "refresh-new" }), + ); + }); + + it("expired token without refresh_token clears store and reauths", async () => { + const expiredNoRefresh: StoredOAuth2Tokens = { + access_token: "old", + expires_at: Date.now() - 1, + }; + + const store: TokenStore = { + load: vi.fn(async () => expiredNoRefresh), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + + const interactiveLoginFn = vi.fn(async () => ({ + access_token: "new", + refresh_token: "refresh", + expires_at: Date.now() + 3600_000, + })); + + const provider = new OAuth2PKCEAuthProvider( + runtime, + store, + vi.fn() as any, + interactiveLoginFn, + ); + + const token = await provider.getAccessToken(); + expect(token).toBe("new"); + expect(store.clear).toHaveBeenCalled(); + expect(interactiveLoginFn).toHaveBeenCalled(); + }); +}); + diff --git a/src/client/__tests__/pkce.test.ts b/src/client/__tests__/pkce.test.ts new file mode 100644 index 0000000..78720d9 --- /dev/null +++ b/src/client/__tests__/pkce.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; +import { createCodeChallenge, base64UrlEncode } from "../auth-providers/pkce"; + +describe("pkce helpers", () => { + it("base64UrlEncode should be url-safe and unpadded", () => { + const out = base64UrlEncode(Buffer.from("hello world")); + expect(out).not.toContain("+"); + expect(out).not.toContain("/"); + expect(out).not.toContain("="); + }); + + it("createCodeChallenge should match known vector", () => { + // RFC 7636 example (verifier => challenge) + const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + const expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + expect(createCodeChallenge(verifier)).toBe(expected); + }); +}); + diff --git a/src/client/__tests__/token-store.test.ts b/src/client/__tests__/token-store.test.ts new file mode 100644 index 0000000..a94d5a0 --- /dev/null +++ b/src/client/__tests__/token-store.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { FileTokenStore, RuntimeCacheTokenStore } from "../auth-providers/token-store"; +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +describe("token-store", () => { + describe("FileTokenStore", () => { + it("roundtrips save/load", async () => { + const path = join(tmpdir(), `twitter-oauth2-tokens-${Date.now()}.json`); + const store = new FileTokenStore(path); + + const tokens = { + access_token: "access", + refresh_token: "refresh", + expires_at: Date.now() + 60_000, + scope: "tweet.read", + token_type: "bearer", + }; + + await store.save(tokens); + const loaded = await store.load(); + expect(loaded).toEqual(tokens); + + await store.clear(); + const cleared = await store.load(); + expect(cleared).toBeNull(); + }); + + it("returns null for corrupted json", async () => { + const path = join(tmpdir(), `twitter-oauth2-tokens-${Date.now()}-bad.json`); + await fs.writeFile(path, "{ not json", "utf-8"); + + const store = new FileTokenStore(path); + const loaded = await store.load(); + expect(loaded).toBeNull(); + + await store.clear(); + }); + }); + + describe("RuntimeCacheTokenStore", () => { + let runtime: any; + + beforeEach(() => { + const cache = new Map(); + runtime = { + agentId: "agent-123", + getCache: vi.fn(async (k: string) => cache.get(k)), + setCache: vi.fn(async (k: string, v: any) => { + cache.set(k, v); + }), + }; + }); + + it("saves and loads via runtime cache", async () => { + const store = new RuntimeCacheTokenStore(runtime); + const tokens = { + access_token: "a", + refresh_token: "r", + expires_at: 123, + }; + + await store.save(tokens); + const loaded = await store.load(); + expect(loaded).toEqual(tokens); + expect(runtime.setCache).toHaveBeenCalled(); + expect(runtime.getCache).toHaveBeenCalled(); + }); + }); +}); + diff --git a/src/client/auth-providers/broker.ts b/src/client/auth-providers/broker.ts new file mode 100644 index 0000000..4afa034 --- /dev/null +++ b/src/client/auth-providers/broker.ts @@ -0,0 +1,33 @@ +import type { IAgentRuntime } from "@elizaos/core"; +import { getSetting } from "../../utils/settings"; +import type { TwitterAuthProvider } from "./types"; + +/** + * Broker-ready scaffolding (stub only). + * + * Future contract idea (v1): + * - GET {TWITTER_BROKER_URL}/v1/twitter/access-token + * -> { access_token: string, expires_at: number } + * + * This plugin intentionally ships NO secrets. The broker would handle client secrets + * and user sessions, returning short-lived access tokens to the agent. + */ +export class BrokerAuthProvider implements TwitterAuthProvider { + readonly mode = "broker" as const; + + constructor(private readonly runtime: IAgentRuntime) {} + + async getAccessToken(): Promise { + const url = getSetting(this.runtime, "TWITTER_BROKER_URL"); + if (!url) { + throw new Error( + "TWITTER_AUTH_MODE=broker requires TWITTER_BROKER_URL (broker not implemented yet).", + ); + } + throw new Error( + `Twitter broker auth is not implemented yet. Configured TWITTER_BROKER_URL=${url}. ` + + "TODO: implement broker contract to fetch short-lived access tokens.", + ); + } +} + diff --git a/src/client/auth-providers/env.ts b/src/client/auth-providers/env.ts new file mode 100644 index 0000000..98f2e32 --- /dev/null +++ b/src/client/auth-providers/env.ts @@ -0,0 +1,59 @@ +import type { IAgentRuntime } from "@elizaos/core"; +import { getSetting } from "../../utils/settings"; +import type { OAuth1Credentials, TwitterOAuth1Provider } from "./types"; + +/** + * Legacy env-var auth provider (OAuth 1.0a user context). + * + * Backward compatible with the existing configuration: + * - TWITTER_API_KEY + * - TWITTER_API_SECRET_KEY + * - TWITTER_ACCESS_TOKEN + * - TWITTER_ACCESS_TOKEN_SECRET + */ +export class EnvAuthProvider implements TwitterOAuth1Provider { + readonly mode = "env" as const; + + constructor( + private readonly runtime?: IAgentRuntime, + private readonly state?: any, + ) {} + + async getOAuth1Credentials(): Promise { + const apiKey = + this.state?.TWITTER_API_KEY ?? getSetting(this.runtime, "TWITTER_API_KEY"); + const apiSecretKey = + this.state?.TWITTER_API_SECRET_KEY ?? + getSetting(this.runtime, "TWITTER_API_SECRET_KEY"); + const accessToken = + this.state?.TWITTER_ACCESS_TOKEN ?? + getSetting(this.runtime, "TWITTER_ACCESS_TOKEN"); + const accessTokenSecret = + this.state?.TWITTER_ACCESS_TOKEN_SECRET ?? + getSetting(this.runtime, "TWITTER_ACCESS_TOKEN_SECRET"); + + const missing: string[] = []; + if (!apiKey) missing.push("TWITTER_API_KEY"); + if (!apiSecretKey) missing.push("TWITTER_API_SECRET_KEY"); + if (!accessToken) missing.push("TWITTER_ACCESS_TOKEN"); + if (!accessTokenSecret) missing.push("TWITTER_ACCESS_TOKEN_SECRET"); + if (missing.length) { + throw new Error( + `Missing required Twitter env credentials: ${missing.join(", ")}`, + ); + } + + return { + appKey: apiKey, + appSecret: apiSecretKey, + accessToken, + accessSecret: accessTokenSecret, + }; + } + + async getAccessToken(): Promise { + const creds = await this.getOAuth1Credentials(); + return creds.accessToken; + } +} + diff --git a/src/client/auth-providers/factory.ts b/src/client/auth-providers/factory.ts new file mode 100644 index 0000000..a23ef24 --- /dev/null +++ b/src/client/auth-providers/factory.ts @@ -0,0 +1,34 @@ +import type { IAgentRuntime } from "@elizaos/core"; +import { getSetting } from "../../utils/settings"; +import type { TwitterAuthMode, TwitterAuthProvider } from "./types"; +import { EnvAuthProvider } from "./env"; +import { OAuth2PKCEAuthProvider } from "./oauth2-pkce"; +import { BrokerAuthProvider } from "./broker"; + +function normalizeMode(v: string | undefined | null): TwitterAuthMode { + const mode = (v ?? "env").toLowerCase(); + if (mode === "env" || mode === "oauth" || mode === "broker") return mode; + throw new Error(`Invalid TWITTER_AUTH_MODE=${v}. Expected env|oauth|broker.`); +} + +export function getTwitterAuthMode(runtime?: IAgentRuntime, state?: any): TwitterAuthMode { + return normalizeMode( + state?.TWITTER_AUTH_MODE ?? getSetting(runtime ?? null, "TWITTER_AUTH_MODE") ?? "env", + ); +} + +export function createTwitterAuthProvider( + runtime: IAgentRuntime, + state?: any, +): TwitterAuthProvider { + const mode = getTwitterAuthMode(runtime, state); + switch (mode) { + case "env": + return new EnvAuthProvider(runtime, state); + case "oauth": + return new OAuth2PKCEAuthProvider(runtime); + case "broker": + return new BrokerAuthProvider(runtime); + } +} + diff --git a/src/client/auth-providers/interactive.ts b/src/client/auth-providers/interactive.ts new file mode 100644 index 0000000..ab9973e --- /dev/null +++ b/src/client/auth-providers/interactive.ts @@ -0,0 +1,119 @@ +import { createServer } from "node:http"; +import { URL } from "node:url"; +import { logger } from "@elizaos/core"; +import * as readline from "node:readline"; + +export interface OAuthCallbackResult { + code: string; + state?: string; +} + +function canPrompt(): boolean { + return !!process.stdin && !!process.stdout && process.stdin.isTTY === true; +} + +export async function promptForRedirectedUrl(promptText: string): Promise { + if (!canPrompt()) { + throw new Error( + "Twitter OAuth requires interactive setup, but stdin is not a TTY. " + + "Re-run with an interactive terminal or use a runtime with persistent settings storage.", + ); + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const question = (q: string) => + new Promise((resolve) => rl.question(q, resolve)); + + try { + const answer = await question(promptText); + return answer.trim(); + } finally { + rl.close(); + } +} + +export async function waitForLoopbackCallback( + redirectUri: string, + expectedState: string, + timeoutMs = 5 * 60 * 1000, +): Promise { + const url = new URL(redirectUri); + if (url.hostname !== "127.0.0.1" && url.hostname !== "localhost") { + throw new Error( + `Redirect URI must be loopback (127.0.0.1/localhost) to use local callback server; got ${url.hostname}`, + ); + } + + // Avoid privileged ports by default. If the user doesn't specify a port, use 8080. + const port = Number(url.port || "8080"); + const path = url.pathname || "/"; + + return await new Promise((resolve, reject) => { + const server = createServer((req, res) => { + try { + const reqUrl = new URL(req.url ?? "", `http://${url.hostname}:${port}`); + if (reqUrl.pathname !== path) { + res.writeHead(404); + res.end("Not Found"); + return; + } + + const code = reqUrl.searchParams.get("code"); + const state = reqUrl.searchParams.get("state") ?? undefined; + const error = reqUrl.searchParams.get("error"); + const errorDesc = reqUrl.searchParams.get("error_description"); + + if (error) { + res.writeHead(400, { "content-type": "text/plain" }); + res.end(`OAuth error: ${error}${errorDesc ? ` - ${errorDesc}` : ""}`); + reject(new Error(`OAuth error: ${error}${errorDesc ? ` - ${errorDesc}` : ""}`)); + server.close(); + return; + } + + if (!code) { + res.writeHead(400, { "content-type": "text/plain" }); + res.end("Missing code"); + return; + } + + if (state && state !== expectedState) { + res.writeHead(400, { "content-type": "text/plain" }); + res.end("State mismatch"); + reject(new Error("OAuth state mismatch")); + server.close(); + return; + } + + res.writeHead(200, { "content-type": "text/plain" }); + res.end("Twitter auth completed. You can close this tab."); + resolve({ code, state }); + server.close(); + } catch (e) { + reject(e instanceof Error ? e : new Error(String(e))); + server.close(); + } + }); + + const timer = setTimeout(() => { + reject(new Error("Timed out waiting for Twitter OAuth callback")); + try { + server.close(); + } catch { + // ignore + } + }, timeoutMs); + + server.on("close", () => clearTimeout(timer)); + server.listen(port, url.hostname, () => { + logger.info( + `Twitter OAuth callback server listening on http://${url.hostname}:${port}${path}`, + ); + }); + }); +} + diff --git a/src/client/auth-providers/oauth2-pkce.ts b/src/client/auth-providers/oauth2-pkce.ts new file mode 100644 index 0000000..7113614 --- /dev/null +++ b/src/client/auth-providers/oauth2-pkce.ts @@ -0,0 +1,245 @@ +import type { IAgentRuntime } from "@elizaos/core"; +import { logger } from "@elizaos/core"; +import { getSetting } from "../../utils/settings"; +import type { TokenStore, StoredOAuth2Tokens } from "./token-store"; +import { chooseDefaultTokenStore } from "./token-store"; +import type { TwitterAuthProvider } from "./types"; +import { createCodeChallenge, createCodeVerifier, createState } from "./pkce"; +import { promptForRedirectedUrl, waitForLoopbackCallback } from "./interactive"; + +const AUTHORIZE_URL = "https://twitter.com/i/oauth2/authorize"; +const TOKEN_URL = "https://api.twitter.com/2/oauth2/token"; + +const DEFAULT_SCOPES = [ + "tweet.read", + "tweet.write", + "users.read", + "offline.access", +].join(" "); + +function nowMs(): number { + return Date.now(); +} + +function isExpired(tokens: StoredOAuth2Tokens, skewMs = 30_000): boolean { + return nowMs() >= tokens.expires_at - skewMs; +} + +function formEncode(params: Record): string { + return Object.entries(params) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join("&"); +} + +export class OAuth2PKCEAuthProvider implements TwitterAuthProvider { + readonly mode = "oauth" as const; + + private tokens: StoredOAuth2Tokens | null = null; + + constructor( + private readonly runtime: IAgentRuntime, + private readonly tokenStore: TokenStore = chooseDefaultTokenStore(runtime), + private readonly fetchImpl: typeof fetch = fetch, + private readonly interactiveLoginFn?: () => Promise, + ) {} + + private get clientId(): string { + const v = getSetting(this.runtime, "TWITTER_CLIENT_ID"); + if (!v) throw new Error("TWITTER_CLIENT_ID is required for TWITTER_AUTH_MODE=oauth"); + return v; + } + + private get redirectUri(): string { + const v = getSetting(this.runtime, "TWITTER_REDIRECT_URI"); + if (!v) throw new Error("TWITTER_REDIRECT_URI is required for TWITTER_AUTH_MODE=oauth"); + return v; + } + + private get scopes(): string { + return getSetting(this.runtime, "TWITTER_SCOPES") || DEFAULT_SCOPES; + } + + private async loadTokens(): Promise { + if (this.tokens) return this.tokens; + this.tokens = await this.tokenStore.load(); + return this.tokens; + } + + private async saveTokens(tokens: StoredOAuth2Tokens): Promise { + this.tokens = tokens; + await this.tokenStore.save(tokens); + } + + private buildAuthorizeUrl(opts: { + state: string; + codeChallenge: string; + }): string { + const url = new URL(AUTHORIZE_URL); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("redirect_uri", this.redirectUri); + url.searchParams.set("scope", this.scopes); + url.searchParams.set("state", opts.state); + url.searchParams.set("code_challenge", opts.codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + return url.toString(); + } + + private async exchangeCodeForToken(params: { + code: string; + codeVerifier: string; + }): Promise { + const body = formEncode({ + grant_type: "authorization_code", + client_id: this.clientId, + redirect_uri: this.redirectUri, + code: params.code, + code_verifier: params.codeVerifier, + }); + + const res = await this.fetchImpl(TOKEN_URL, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + }); + + const json = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error( + `Twitter token exchange failed (${res.status}): ${JSON.stringify(json)}`, + ); + } + + const access = json.access_token as string | undefined; + const refresh = json.refresh_token as string | undefined; + const expiresIn = json.expires_in as number | undefined; + + if (!access || typeof access !== "string") { + throw new Error("Twitter token exchange returned no access_token"); + } + if (!expiresIn || typeof expiresIn !== "number") { + throw new Error("Twitter token exchange returned no expires_in"); + } + + return { + access_token: access, + refresh_token: refresh, + expires_at: nowMs() + expiresIn * 1000, + scope: typeof json.scope === "string" ? json.scope : undefined, + token_type: typeof json.token_type === "string" ? json.token_type : undefined, + }; + } + + private async refreshAccessToken(refreshToken: string): Promise { + const body = formEncode({ + grant_type: "refresh_token", + client_id: this.clientId, + refresh_token: refreshToken, + }); + + const res = await this.fetchImpl(TOKEN_URL, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + }); + + const json = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error( + `Twitter token refresh failed (${res.status}): ${JSON.stringify(json)}`, + ); + } + + const access = json.access_token as string | undefined; + const refresh = (json.refresh_token as string | undefined) ?? refreshToken; + const expiresIn = json.expires_in as number | undefined; + + if (!access || typeof access !== "string") { + throw new Error("Twitter token refresh returned no access_token"); + } + if (!expiresIn || typeof expiresIn !== "number") { + throw new Error("Twitter token refresh returned no expires_in"); + } + + return { + access_token: access, + refresh_token: refresh, + expires_at: nowMs() + expiresIn * 1000, + scope: typeof json.scope === "string" ? json.scope : undefined, + token_type: typeof json.token_type === "string" ? json.token_type : undefined, + }; + } + + private async interactiveLogin(): Promise { + const verifier = createCodeVerifier(); + const challenge = createCodeChallenge(verifier); + const state = createState(); + const authorizeUrl = this.buildAuthorizeUrl({ + state, + codeChallenge: challenge, + }); + + logger.info("Twitter OAuth (PKCE) setup required."); + logger.info(`Open this URL to authorize: ${authorizeUrl}`); + + let code: string | undefined; + try { + // Preferred UX: loopback callback if redirect URI is loopback. + const cb = await waitForLoopbackCallback(this.redirectUri, state); + code = cb.code; + } catch (e) { + logger.warn( + `Could not start loopback callback server (will fall back to paste URL): ${e instanceof Error ? e.message : String(e)}`, + ); + } + + if (!code) { + const redirected = await promptForRedirectedUrl( + "Paste the FULL redirected URL here (it contains ?code=...&state=...): ", + ); + const parsed = new URL(redirected); + const parsedCode = parsed.searchParams.get("code"); + const parsedState = parsed.searchParams.get("state"); + if (!parsedCode) throw new Error("Pasted URL did not include ?code="); + if (parsedState && parsedState !== state) { + throw new Error("OAuth state mismatch"); + } + code = parsedCode; + } + + return await this.exchangeCodeForToken({ code, codeVerifier: verifier }); + } + + async getAccessToken(): Promise { + const tokens = await this.loadTokens(); + if (!tokens) { + const newTokens = this.interactiveLoginFn + ? await this.interactiveLoginFn() + : await this.interactiveLogin(); + await this.saveTokens(newTokens); + return newTokens.access_token; + } + + if (!isExpired(tokens)) { + return tokens.access_token; + } + + if (!tokens.refresh_token) { + // No refresh token available; must re-auth. + await this.tokenStore.clear(); + this.tokens = null; + const newTokens = this.interactiveLoginFn + ? await this.interactiveLoginFn() + : await this.interactiveLogin(); + await this.saveTokens(newTokens); + return newTokens.access_token; + } + + const refreshed = await this.refreshAccessToken(tokens.refresh_token); + await this.saveTokens(refreshed); + return refreshed.access_token; + } +} + +export const OAUTH2_DEFAULT_SCOPES = DEFAULT_SCOPES; + diff --git a/src/client/auth-providers/pkce.ts b/src/client/auth-providers/pkce.ts new file mode 100644 index 0000000..910dce7 --- /dev/null +++ b/src/client/auth-providers/pkce.ts @@ -0,0 +1,24 @@ +import { createHash, randomBytes } from "node:crypto"; + +export function base64UrlEncode(input: Buffer): string { + return input + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +export function createCodeVerifier(byteLength = 32): string { + // RFC 7636: code_verifier length 43-128 chars. 32 bytes => 43 chars in base64url. + return base64UrlEncode(randomBytes(byteLength)); +} + +export function createCodeChallenge(verifier: string): string { + const hash = createHash("sha256").update(verifier).digest(); + return base64UrlEncode(hash); +} + +export function createState(byteLength = 16): string { + return base64UrlEncode(randomBytes(byteLength)); +} + diff --git a/src/client/auth-providers/token-store.ts b/src/client/auth-providers/token-store.ts new file mode 100644 index 0000000..f6d8f18 --- /dev/null +++ b/src/client/auth-providers/token-store.ts @@ -0,0 +1,93 @@ +import { promises as fs } from "node:fs"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; +import type { IAgentRuntime } from "@elizaos/core"; +import { logger } from "@elizaos/core"; + +export interface StoredOAuth2Tokens { + access_token: string; + refresh_token?: string; + expires_at: number; // epoch ms + scope?: string; + token_type?: string; +} + +export interface TokenStore { + load(): Promise; + save(tokens: StoredOAuth2Tokens): Promise; + clear(): Promise; +} + +export class RuntimeCacheTokenStore implements TokenStore { + private readonly key: string; + constructor(private readonly runtime: IAgentRuntime, key?: string) { + this.key = key ?? `twitter/oauth2/tokens/${runtime.agentId}`; + } + + async load(): Promise { + try { + const v = await this.runtime.getCache(this.key); + return v ?? null; + } catch { + return null; + } + } + + async save(tokens: StoredOAuth2Tokens): Promise { + await this.runtime.setCache(this.key, tokens); + } + + async clear(): Promise { + await this.runtime.setCache(this.key, null as any); + } +} + +export class FileTokenStore implements TokenStore { + constructor(private readonly path: string) {} + + static defaultPath(): string { + // Explicit warning is logged by the provider when this fallback is used. + return join(homedir(), ".eliza", "twitter", "oauth2.tokens.json"); + } + + async load(): Promise { + try { + const raw = await fs.readFile(this.path, "utf-8"); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") return null; + if (typeof parsed.access_token !== "string") return null; + if (typeof parsed.expires_at !== "number") return null; + return parsed as StoredOAuth2Tokens; + } catch { + return null; + } + } + + async save(tokens: StoredOAuth2Tokens): Promise { + await fs.mkdir(dirname(this.path), { recursive: true }); + await fs.writeFile(this.path, JSON.stringify(tokens, null, 2), "utf-8"); + } + + async clear(): Promise { + try { + await fs.unlink(this.path); + } catch { + // ignore + } + } +} + +export function chooseDefaultTokenStore( + runtime: IAgentRuntime | undefined, +): TokenStore { + if (runtime && typeof runtime.getCache === "function" && typeof runtime.setCache === "function") { + return new RuntimeCacheTokenStore(runtime); + } + + logger.warn( + "Twitter OAuth token persistence: runtime cache API not available; falling back to local token file. " + + "This file contains sensitive tokens—protect it and rotate tokens if compromised.", + ); + return new FileTokenStore(FileTokenStore.defaultPath()); +} + diff --git a/src/client/auth-providers/types.ts b/src/client/auth-providers/types.ts new file mode 100644 index 0000000..898acdb --- /dev/null +++ b/src/client/auth-providers/types.ts @@ -0,0 +1,41 @@ +import type { IAgentRuntime } from "@elizaos/core"; + +export type TwitterAuthMode = "env" | "oauth" | "broker"; + +/** + * Primary abstraction: obtain a valid access token for Twitter/X API calls. + * + * - For OAuth2 PKCE mode, this is the OAuth2 user access token (Bearer). + * - For env mode (legacy OAuth1.0a), this returns the OAuth1 access token string + * (and the provider may expose additional fields via `getOAuth1Credentials()`). + */ +export interface TwitterAuthProvider { + readonly mode: TwitterAuthMode; + + /** + * Returns a valid access token string. + * Implementations should refresh/reauth as needed. + */ + getAccessToken(): Promise; +} + +export interface OAuth1Credentials { + appKey: string; + appSecret: string; + accessToken: string; + accessSecret: string; +} + +/** + * Optional capability used to keep full backward compatibility with the existing OAuth1.0a flow. + * Consumers should not depend on this unless they need OAuth1 signing. + */ +export interface TwitterOAuth1Provider extends TwitterAuthProvider { + getOAuth1Credentials(): Promise; +} + +export interface TwitterAuthProviderFactoryOptions { + runtime: IAgentRuntime; + state?: any; +} + diff --git a/src/client/auth.ts b/src/client/auth.ts index 9e7c5ff..b6ae76a 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -1,5 +1,6 @@ import { TwitterApi } from "twitter-api-v2"; import { Profile } from "./profile"; +import type { TwitterAuthProvider, TwitterOAuth1Provider } from "./auth-providers/types"; /** * Twitter API v2 authentication using developer credentials @@ -8,30 +9,54 @@ export class TwitterAuth { private v2Client: TwitterApi | null = null; private authenticated = false; private profile?: Profile; + private loggedOut = false; - constructor( - private appKey: string, - private appSecret: string, - private accessToken: string, - private accessSecret: string, - ) { - this.initializeClient(); + private lastAccessToken?: string; + + constructor(private readonly provider: TwitterAuthProvider) { + // Backward-compatible behavior: legacy OAuth1 provider is considered authenticated immediately, + // matching previous eager client initialization semantics. + if (typeof (provider as any).getOAuth1Credentials === "function") { + this.authenticated = true; + } } - private initializeClient(): void { - this.v2Client = new TwitterApi({ - appKey: this.appKey, - appSecret: this.appSecret, - accessToken: this.accessToken, - accessSecret: this.accessSecret, - }); - this.authenticated = true; + private isOAuth1Provider(p: TwitterAuthProvider): p is TwitterOAuth1Provider { + return typeof (p as any).getOAuth1Credentials === "function"; + } + + private async ensureClientInitialized(): Promise { + if (this.loggedOut) { + throw new Error("Twitter API client not initialized"); + } + if (this.isOAuth1Provider(this.provider)) { + if (this.v2Client) return; + const creds = await this.provider.getOAuth1Credentials(); + this.v2Client = new TwitterApi({ + appKey: creds.appKey, + appSecret: creds.appSecret, + accessToken: creds.accessToken, + accessSecret: creds.accessSecret, + }); + this.authenticated = true; + this.lastAccessToken = creds.accessToken; + return; + } + + const token = await this.provider.getAccessToken(); + if (!this.v2Client || this.lastAccessToken !== token) { + // OAuth2 user context token: Bearer token + this.v2Client = new TwitterApi(token); + this.authenticated = true; + this.lastAccessToken = token; + } } /** * Get the Twitter API v2 client */ - getV2Client(): TwitterApi { + async getV2Client(): Promise { + await this.ensureClientInitialized(); if (!this.v2Client) { throw new Error("Twitter API client not initialized"); } @@ -42,6 +67,11 @@ export class TwitterAuth { * Check if authenticated */ async isLoggedIn(): Promise { + try { + await this.ensureClientInitialized(); + } catch { + return false; + } if (!this.authenticated || !this.v2Client) { return false; } @@ -64,6 +94,7 @@ export class TwitterAuth { return this.profile; } + await this.ensureClientInitialized(); if (!this.v2Client) { throw new Error("Not authenticated"); } @@ -110,12 +141,14 @@ export class TwitterAuth { this.v2Client = null; this.authenticated = false; this.profile = undefined; + this.lastAccessToken = undefined; + this.loggedOut = true; } /** * For compatibility - always returns true since we use API keys */ hasToken(): boolean { - return this.authenticated; + return this.authenticated && !this.loggedOut; } } diff --git a/src/client/client.ts b/src/client/client.ts index cd42c33..09a4a99 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -13,6 +13,7 @@ import { type RequestApiResult, } from "./api-types"; import { TwitterAuth } from "./auth"; +import type { TwitterAuthProvider } from "./auth-providers/types"; // Removed messages imports - using Twitter API v2 instead import { type Profile, @@ -303,7 +304,7 @@ export class Client { throw new Error("Not authenticated"); } - const client = this.auth.getV2Client(); + const client = await this.auth.getV2Client(); try { const timeline = await client.v2.homeTimeline({ @@ -366,7 +367,7 @@ export class Client { throw new Error("Not authenticated"); } - const client = this.auth.getV2Client(); + const client = await this.auth.getV2Client(); try { const response = await client.v2.userTimeline(userId, { @@ -721,6 +722,12 @@ export class Client { this.auth = auth; } + public async authenticate(provider: TwitterAuthProvider): Promise { + this.auth = new TwitterAuth(provider); + // Force initialization early to surface misconfiguration quickly + await this.auth.isLoggedIn().catch(() => false); + } + /** * Get current authentication credentials * @returns {TwitterAuth | null} Current authentication or null if not authenticated @@ -778,7 +785,17 @@ export class Client { ); } - this.auth = new TwitterAuth(appKey, appSecret, accessToken, accessSecret); + // Backward compatible path: build a fixed OAuth1 provider inline. + this.auth = new TwitterAuth({ + mode: "env", + getAccessToken: async () => accessToken!, + getOAuth1Credentials: async () => ({ + appKey: appKey!, + appSecret: appSecret!, + accessToken: accessToken!, + accessSecret: accessSecret!, + }), + } as any); } /** diff --git a/src/client/profile.ts b/src/client/profile.ts index cc09303..2da6a5e 100644 --- a/src/client/profile.ts +++ b/src/client/profile.ts @@ -228,7 +228,7 @@ export async function getProfile( } try { - const client = auth.getV2Client(); + const client = await auth.getV2Client(); const user = await client.v2.userByUsername(username, { "user.fields": [ "id", @@ -281,7 +281,7 @@ export async function getScreenNameByUserId( } try { - const client = auth.getV2Client(); + const client = await auth.getV2Client(); const user = await client.v2.user(userId, { "user.fields": ["username"], }); diff --git a/src/client/relationships.ts b/src/client/relationships.ts index 0804ceb..61abe05 100644 --- a/src/client/relationships.ts +++ b/src/client/relationships.ts @@ -46,7 +46,7 @@ export async function* getFollowing( throw new Error("Not authenticated"); } - const client = auth.getV2Client(); + const client = await auth.getV2Client(); let count = 0; let paginationToken: string | undefined; @@ -108,7 +108,7 @@ export async function* getFollowers( throw new Error("Not authenticated"); } - const client = auth.getV2Client(); + const client = await auth.getV2Client(); let count = 0; let paginationToken: string | undefined; @@ -172,7 +172,7 @@ export async function fetchProfileFollowing( throw new Error("Not authenticated"); } - const client = auth.getV2Client(); + const client = await auth.getV2Client(); try { const response = await client.v2.following(userId, { @@ -227,7 +227,7 @@ export async function fetchProfileFollowers( throw new Error("Not authenticated"); } - const client = auth.getV2Client(); + const client = await auth.getV2Client(); try { const response = await client.v2.followers(userId, { @@ -278,7 +278,7 @@ export async function followUser( throw new Error("Not authenticated"); } - const client = auth.getV2Client(); + const client = await auth.getV2Client(); try { // First get the user ID from username diff --git a/src/client/search.ts b/src/client/search.ts index 2230114..01d628d 100644 --- a/src/client/search.ts +++ b/src/client/search.ts @@ -33,7 +33,7 @@ export async function* searchTweets( searchMode: SearchMode, auth: TwitterAuth, ): AsyncGenerator { - const client = auth.getV2Client(); + const client = await auth.getV2Client(); // Build query based on search mode let finalQuery = query; @@ -141,7 +141,7 @@ export async function* searchProfiles( maxProfiles: number, auth: TwitterAuth, ): AsyncGenerator { - const client = auth.getV2Client(); + const client = await auth.getV2Client(); const userIds = new Set(); const profiles: Profile[] = []; diff --git a/src/client/tweets.ts b/src/client/tweets.ts index 592b0bb..1750cb4 100644 --- a/src/client/tweets.ts +++ b/src/client/tweets.ts @@ -301,7 +301,7 @@ export async function fetchTweets( cursor: string | undefined, auth: TwitterAuth, ): Promise { - const client = auth.getV2Client(); + const client = await auth.getV2Client(); try { const response = await client.v2.userTimeline(userId, { @@ -350,7 +350,7 @@ export async function fetchTweetsAndReplies( cursor: string | undefined, auth: TwitterAuth, ): Promise { - const client = auth.getV2Client(); + const client = await auth.getV2Client(); try { const response = await client.v2.userTimeline(userId, { @@ -402,7 +402,7 @@ export async function createCreateTweetRequestV2( poll?: PollData; }, ) { - const v2client = auth.getV2Client(); + const v2client = await auth.getV2Client(); if (v2client == null) { throw new Error("V2 client is not initialized"); } @@ -571,7 +571,7 @@ export async function createCreateTweetRequest( mediaData?: { data: Buffer; mediaType: string }[], hideLinkPreview = false, ) { - const v2client = auth.getV2Client(); + const v2client = await auth.getV2Client(); if (!v2client) { throw new Error("V2 client is not initialized"); } @@ -624,7 +624,7 @@ export async function fetchListTweets( cursor: string | undefined, auth: TwitterAuth, ): Promise { - const client = auth.getV2Client(); + const client = await auth.getV2Client(); try { const response = await client.v2.listTweets(listId, { @@ -667,7 +667,7 @@ export async function fetchListTweets( } export async function deleteTweet(tweetId: string, auth: TwitterAuth) { - const v2client = auth.getV2Client(); + const v2client = await auth.getV2Client(); if (!v2client) { throw new Error("V2 client is not initialized"); } @@ -814,7 +814,7 @@ export async function fetchLikedTweets( cursor: string | undefined, auth: TwitterAuth, ): Promise { - const client = auth.getV2Client(); + const client = await auth.getV2Client(); try { const response = await client.v2.userLikedTweets(userId, { @@ -919,7 +919,7 @@ export async function getTweet( id: string, auth: TwitterAuth, ): Promise { - const client = auth.getV2Client(); + const client = await auth.getV2Client(); try { const tweet = await client.v2.singleTweet(id, { @@ -968,7 +968,7 @@ export async function getTweetV2( placeFields?: TTweetv2PlaceField[]; } = defaultOptions, ): Promise { - const v2client = auth.getV2Client(); + const v2client = await auth.getV2Client(); if (!v2client) { throw new Error("V2 client is not initialized"); } @@ -1010,7 +1010,7 @@ export async function getTweetsV2( placeFields?: TTweetv2PlaceField[]; } = defaultOptions, ): Promise { - const v2client = auth.getV2Client(); + const v2client = await auth.getV2Client(); if (!v2client) { return []; } @@ -1080,7 +1080,7 @@ export async function createQuoteTweetRequest( auth: TwitterAuth, mediaData?: { data: Buffer; mediaType: string }[], ) { - const v2client = auth.getV2Client(); + const v2client = await auth.getV2Client(); if (!v2client) { throw new Error("V2 client is not initialized"); } @@ -1114,7 +1114,7 @@ export async function likeTweet( tweetId: string, auth: TwitterAuth, ): Promise { - const v2client = auth.getV2Client(); + const v2client = await auth.getV2Client(); if (!v2client) { throw new Error("V2 client is not initialized"); } @@ -1139,7 +1139,7 @@ export async function retweet( tweetId: string, auth: TwitterAuth, ): Promise { - const v2client = auth.getV2Client(); + const v2client = await auth.getV2Client(); if (!v2client) { throw new Error("V2 client is not initialized"); } diff --git a/src/environment.ts b/src/environment.ts index 02db2a0..0648202 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -8,11 +8,24 @@ import { z } from "zod"; * All time intervals are in minutes for consistency */ export const twitterEnvSchema = z.object({ + // Auth mode (backward compatible default) + TWITTER_AUTH_MODE: z.enum(["env", "oauth", "broker"]).default("env"), + // Required API credentials - TWITTER_API_KEY: z.string(), - TWITTER_API_SECRET_KEY: z.string(), - TWITTER_ACCESS_TOKEN: z.string(), - TWITTER_ACCESS_TOKEN_SECRET: z.string(), + TWITTER_API_KEY: z.string().default(""), + TWITTER_API_SECRET_KEY: z.string().default(""), + TWITTER_ACCESS_TOKEN: z.string().default(""), + TWITTER_ACCESS_TOKEN_SECRET: z.string().default(""), + + // OAuth2 PKCE (3-legged) configuration + TWITTER_CLIENT_ID: z.string().default(""), + TWITTER_REDIRECT_URI: z.string().default(""), + TWITTER_SCOPES: z + .string() + .default("tweet.read tweet.write users.read offline.access"), + + // Broker scaffolding (stub) + TWITTER_BROKER_URL: z.string().default(""), // Core configuration TWITTER_DRY_RUN: z.string().default("false"), @@ -26,7 +39,7 @@ export const twitterEnvSchema = z.object({ // Timing configuration (all in minutes) TWITTER_POST_INTERVAL: z.string().default("120"), // minutes between posts (deprecated, kept for backwards compatibility) TWITTER_POST_INTERVAL_MIN: z.string().default("90"), // minimum minutes between posts - TWITTER_POST_INTERVAL_MAX: z.string().default("150"), // maximum minutes between posts + TWITTER_POST_INTERVAL_MAX: z.string().default("180"), // maximum minutes between posts TWITTER_ENGAGEMENT_INTERVAL: z.string().default("30"), // minutes between all interactions (deprecated, kept for backwards compatibility) TWITTER_ENGAGEMENT_INTERVAL_MIN: z.string().default("20"), // minimum minutes between engagements TWITTER_ENGAGEMENT_INTERVAL_MAX: z.string().default("40"), // maximum minutes between engagements @@ -108,7 +121,15 @@ export async function validateTwitterConfig( config: Partial = {}, ): Promise { try { + const rawMode = + (config as any).TWITTER_AUTH_MODE ?? + (getSetting(runtime, "TWITTER_AUTH_MODE") as any) ?? + "env"; + const normalizedMode = + typeof rawMode === "string" && rawMode.trim() ? rawMode.trim() : "env"; + const validatedConfig: TwitterConfig = { + TWITTER_AUTH_MODE: normalizedMode as any, TWITTER_API_KEY: config.TWITTER_API_KEY ?? getSetting(runtime, "TWITTER_API_KEY") ?? "", TWITTER_API_SECRET_KEY: @@ -123,6 +144,22 @@ export async function validateTwitterConfig( config.TWITTER_ACCESS_TOKEN_SECRET ?? getSetting(runtime, "TWITTER_ACCESS_TOKEN_SECRET") ?? "", + TWITTER_CLIENT_ID: + (config as any).TWITTER_CLIENT_ID ?? + getSetting(runtime, "TWITTER_CLIENT_ID") ?? + "", + TWITTER_REDIRECT_URI: + (config as any).TWITTER_REDIRECT_URI ?? + getSetting(runtime, "TWITTER_REDIRECT_URI") ?? + "", + TWITTER_SCOPES: + (config as any).TWITTER_SCOPES ?? + getSetting(runtime, "TWITTER_SCOPES") ?? + "tweet.read tweet.write users.read offline.access", + TWITTER_BROKER_URL: + (config as any).TWITTER_BROKER_URL ?? + getSetting(runtime, "TWITTER_BROKER_URL") ?? + "", TWITTER_DRY_RUN: String( ( config.TWITTER_DRY_RUN ?? @@ -173,7 +210,7 @@ export async function validateTwitterConfig( safeParseInt( config.TWITTER_POST_INTERVAL_MAX ?? getSetting(runtime, "TWITTER_POST_INTERVAL_MAX"), - 150, + 180, ), ), TWITTER_ENGAGEMENT_INTERVAL: String( @@ -235,21 +272,43 @@ export async function validateTwitterConfig( }; // Validate required credentials - if ( - !validatedConfig.TWITTER_API_KEY || - !validatedConfig.TWITTER_API_SECRET_KEY || - !validatedConfig.TWITTER_ACCESS_TOKEN || - !validatedConfig.TWITTER_ACCESS_TOKEN_SECRET - ) { + const mode = (validatedConfig.TWITTER_AUTH_MODE || "env").toLowerCase(); + if (mode === "env") { + if ( + !validatedConfig.TWITTER_API_KEY || + !validatedConfig.TWITTER_API_SECRET_KEY || + !validatedConfig.TWITTER_ACCESS_TOKEN || + !validatedConfig.TWITTER_ACCESS_TOKEN_SECRET + ) { + throw new Error( + "Twitter env auth is selected (TWITTER_AUTH_MODE=env). Please set TWITTER_API_KEY, TWITTER_API_SECRET_KEY, TWITTER_ACCESS_TOKEN, and TWITTER_ACCESS_TOKEN_SECRET", + ); + } + } else if (mode === "oauth") { + if (!validatedConfig.TWITTER_CLIENT_ID || !validatedConfig.TWITTER_REDIRECT_URI) { + throw new Error( + "Twitter OAuth is selected (TWITTER_AUTH_MODE=oauth). Please set TWITTER_CLIENT_ID and TWITTER_REDIRECT_URI", + ); + } + } else if (mode === "broker") { + if (!validatedConfig.TWITTER_BROKER_URL) { + throw new Error( + "Twitter broker auth is selected (TWITTER_AUTH_MODE=broker). Please set TWITTER_BROKER_URL", + ); + } + } else { throw new Error( - "Twitter API credentials are required. Please set TWITTER_API_KEY, TWITTER_API_SECRET_KEY, TWITTER_ACCESS_TOKEN, and TWITTER_ACCESS_TOKEN_SECRET", + `Invalid TWITTER_AUTH_MODE=${validatedConfig.TWITTER_AUTH_MODE}. Expected env|oauth|broker.`, ); } return twitterEnvSchema.parse(validatedConfig); } catch (error) { if (error instanceof z.ZodError) { - const errorMessages = error.errors + const issues: Array<{ path: (string | number)[]; message: string }> = + // zod v3 uses `issues`; some builds also expose `errors` + ((error as any).issues ?? (error as any).errors ?? []) as any; + const errorMessages = issues .map((err) => `${err.path.join(".")}: ${err.message}`) .join(", "); throw new Error( @@ -298,10 +357,17 @@ function getDefaultConfig(): TwitterConfig { }; return { + TWITTER_AUTH_MODE: (getConfig("TWITTER_AUTH_MODE") as any) || "env", TWITTER_API_KEY: getConfig("TWITTER_API_KEY") || "", TWITTER_API_SECRET_KEY: getConfig("TWITTER_API_SECRET_KEY") || "", TWITTER_ACCESS_TOKEN: getConfig("TWITTER_ACCESS_TOKEN") || "", TWITTER_ACCESS_TOKEN_SECRET: getConfig("TWITTER_ACCESS_TOKEN_SECRET") || "", + TWITTER_CLIENT_ID: getConfig("TWITTER_CLIENT_ID") || "", + TWITTER_REDIRECT_URI: getConfig("TWITTER_REDIRECT_URI") || "", + TWITTER_SCOPES: + getConfig("TWITTER_SCOPES") || + "tweet.read tweet.write users.read offline.access", + TWITTER_BROKER_URL: getConfig("TWITTER_BROKER_URL") || "", TWITTER_DRY_RUN: getConfig("TWITTER_DRY_RUN") || "false", TWITTER_TARGET_USERS: getConfig("TWITTER_TARGET_USERS") || "", TWITTER_ENABLE_POST: getConfig("TWITTER_ENABLE_POST") || "false", @@ -309,7 +375,7 @@ function getDefaultConfig(): TwitterConfig { TWITTER_ENABLE_ACTIONS: getConfig("TWITTER_ENABLE_ACTIONS") || "false", TWITTER_POST_INTERVAL: getConfig("TWITTER_POST_INTERVAL") || "120", TWITTER_POST_INTERVAL_MIN: getConfig("TWITTER_POST_INTERVAL_MIN") || "90", - TWITTER_POST_INTERVAL_MAX: getConfig("TWITTER_POST_INTERVAL_MAX") || "150", + TWITTER_POST_INTERVAL_MAX: getConfig("TWITTER_POST_INTERVAL_MAX") || "180", TWITTER_ENGAGEMENT_INTERVAL: getConfig("TWITTER_ENGAGEMENT_INTERVAL") || "30", TWITTER_ENGAGEMENT_INTERVAL_MIN: getConfig("TWITTER_ENGAGEMENT_INTERVAL_MIN") || "20", diff --git a/src/index.ts b/src/index.ts index 6e328f9..cb0cd87 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { type IAgentRuntime, logger, type Plugin } from "@elizaos/core"; import { TwitterService } from "./services/twitter.service.js"; import { postTweetAction } from "./actions/postTweet.js"; +import { getSetting } from "./utils/settings"; export const TwitterPlugin: Plugin = { name: "twitter", @@ -12,38 +13,53 @@ export const TwitterPlugin: Plugin = { // Only do validation in init, don't start services logger.log("🔧 Initializing Twitter plugin..."); - // Check if we can access settings - const hasGetSetting = runtime && typeof runtime.getSetting === "function"; + const mode = (getSetting(runtime, "TWITTER_AUTH_MODE") || "env").toLowerCase(); - // Basic validation of required settings - const apiKey = hasGetSetting - ? runtime.getSetting("TWITTER_API_KEY") - : process.env.TWITTER_API_KEY; - const apiSecretKey = hasGetSetting - ? runtime.getSetting("TWITTER_API_SECRET_KEY") - : process.env.TWITTER_API_SECRET_KEY; - const accessToken = hasGetSetting - ? runtime.getSetting("TWITTER_ACCESS_TOKEN") - : process.env.TWITTER_ACCESS_TOKEN; - const accessTokenSecret = hasGetSetting - ? runtime.getSetting("TWITTER_ACCESS_TOKEN_SECRET") - : process.env.TWITTER_ACCESS_TOKEN_SECRET; + if (mode === "env") { + const apiKey = getSetting(runtime, "TWITTER_API_KEY"); + const apiSecretKey = getSetting(runtime, "TWITTER_API_SECRET_KEY"); + const accessToken = getSetting(runtime, "TWITTER_ACCESS_TOKEN"); + const accessTokenSecret = getSetting(runtime, "TWITTER_ACCESS_TOKEN_SECRET"); - if (!apiKey || !apiSecretKey || !accessToken || !accessTokenSecret) { - const missing = []; - if (!apiKey) missing.push("TWITTER_API_KEY"); - if (!apiSecretKey) missing.push("TWITTER_API_SECRET_KEY"); - if (!accessToken) missing.push("TWITTER_ACCESS_TOKEN"); - if (!accessTokenSecret) missing.push("TWITTER_ACCESS_TOKEN_SECRET"); + if (!apiKey || !apiSecretKey || !accessToken || !accessTokenSecret) { + const missing = []; + if (!apiKey) missing.push("TWITTER_API_KEY"); + if (!apiSecretKey) missing.push("TWITTER_API_SECRET_KEY"); + if (!accessToken) missing.push("TWITTER_ACCESS_TOKEN"); + if (!accessTokenSecret) missing.push("TWITTER_ACCESS_TOKEN_SECRET"); + logger.warn( + `Twitter env auth not configured - Twitter functionality will be limited. Missing: ${missing.join(", ")}`, + ); + } else { + logger.log("✅ Twitter env credentials found"); + } + } else if (mode === "oauth") { + const clientId = getSetting(runtime, "TWITTER_CLIENT_ID"); + const redirectUri = getSetting(runtime, "TWITTER_REDIRECT_URI"); + if (!clientId || !redirectUri) { + const missing = []; + if (!clientId) missing.push("TWITTER_CLIENT_ID"); + if (!redirectUri) missing.push("TWITTER_REDIRECT_URI"); + logger.warn( + `Twitter OAuth not configured - Twitter functionality will be limited. Missing: ${missing.join(", ")}`, + ); + } else { + logger.log("✅ Twitter OAuth configuration found"); + } + } else if (mode === "broker") { + const brokerUrl = getSetting(runtime, "TWITTER_BROKER_URL"); + if (!brokerUrl) { + logger.warn( + "TWITTER_AUTH_MODE=broker requires TWITTER_BROKER_URL (broker auth is not implemented yet).", + ); + } else { + logger.log("ℹ️ Twitter broker mode configured (stub; not functional yet)"); + } + } else { logger.warn( - `Twitter API credentials not configured - Twitter functionality will be limited. Missing: ${missing.join(", ")}`, - ); - logger.warn( - "To enable Twitter functionality, please provide the missing credentials in your .env file", + `Invalid TWITTER_AUTH_MODE=${mode}. Expected env|oauth|broker.`, ); - } else { - logger.log("✅ Twitter credentials found"); } }, }; diff --git a/src/services/MessageService.ts b/src/services/MessageService.ts index 8b2739f..b2d2080 100644 --- a/src/services/MessageService.ts +++ b/src/services/MessageService.ts @@ -13,6 +13,40 @@ import { SearchMode } from "../client"; export class TwitterMessageService implements IMessageService { constructor(private client: ClientBase) {} + private extractRestId(result: any): string | undefined { + return ( + result?.rest_id ?? + result?.data?.create_tweet?.tweet_results?.result?.rest_id ?? + result?.data?.data?.create_tweet?.tweet_results?.result?.rest_id ?? + undefined + ); + } + + private async extractResultId(result: any): Promise { + const direct = + result?.id ?? result?.data?.id ?? result?.data?.data?.id ?? undefined; + if (direct) return direct; + const restId = this.extractRestId(result); + if (restId) return restId; + + if (result?.json && typeof result.json === "function") { + try { + const body = await result.json(); + return ( + body?.id ?? + body?.data?.id ?? + body?.data?.data?.id ?? + this.extractRestId(body) ?? + undefined + ); + } catch { + return undefined; + } + } + + return undefined; + } + async getMessages(options: GetMessagesOptions): Promise { try { // Twitter doesn't have a direct way to get messages by room ID @@ -86,7 +120,7 @@ export class TwitterMessageService implements IMessageService { } const message: Message = { - id: result.id, + id: (await this.extractResultId(result)) || (result?.id as any), agentId: options.agentId, roomId: options.roomId, userId: this.client.profile?.id || "", diff --git a/src/services/PostService.ts b/src/services/PostService.ts index 5bf0196..fa555bf 100644 --- a/src/services/PostService.ts +++ b/src/services/PostService.ts @@ -12,6 +12,41 @@ import { SearchMode } from "../client"; export class TwitterPostService implements IPostService { constructor(private client: ClientBase) {} + private extractRestId(result: any): string | undefined { + return ( + result?.rest_id ?? + result?.data?.create_tweet?.tweet_results?.result?.rest_id ?? + result?.data?.data?.create_tweet?.tweet_results?.result?.rest_id ?? + undefined + ); + } + + private async extractTweetId(result: any): Promise { + const direct = + result?.id ?? result?.data?.id ?? result?.data?.data?.id ?? undefined; + if (direct) return direct; + const restId = this.extractRestId(result); + if (restId) return restId; + + // Some callers return a Response-like shape with a json() function. + if (result?.json && typeof result.json === "function") { + try { + const body = await result.json(); + return ( + body?.id ?? + body?.data?.id ?? + body?.data?.data?.id ?? + this.extractRestId(body) ?? + undefined + ); + } catch { + return undefined; + } + } + + return undefined; + } + async createPost(options: CreatePostOptions): Promise { try { // Handle media uploads if needed @@ -28,7 +63,14 @@ export class TwitterPostService implements IPostService { // TODO: Add media support when available ); - const tweetId = (result as any).id || Date.now().toString(); + const tweetId = + (await this.extractTweetId(result)) || + (() => { + logger.warn( + "Twitter createPost: could not extract tweet id from API result; falling back to timestamp id", + ); + return Date.now().toString(); + })(); const post: Post = { id: tweetId, diff --git a/tsup.config.ts b/tsup.config.ts index a9734ba..6c78300 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -12,6 +12,15 @@ export default defineConfig({ 'dotenv', // Externalize dotenv to prevent bundling 'fs', // Externalize fs to use Node.js built-in module 'path', // Externalize other built-ins if necessary + // Also externalize node: prefixed built-ins (used by this plugin) + 'node:fs', + 'node:path', + 'node:http', + 'node:https', + 'node:crypto', + 'node:os', + 'node:url', + 'node:readline', '@reflink/reflink', '@node-llama-cpp', 'https', From e01cf0171bdd37f0fec9ffe0004b629b21ceb9c1 Mon Sep 17 00:00:00 2001 From: Vivek Kotecha Date: Wed, 31 Dec 2025 17:47:55 +0530 Subject: [PATCH 2/3] fix: address auth storage + callback edge cases --- src/client/__tests__/token-store.test.ts | 14 ++++++++++ src/client/auth-providers/interactive.ts | 35 ++++++++++++++---------- src/client/auth-providers/token-store.ts | 18 ++++++++++-- src/services/PostService.ts | 23 ++++++++++------ 4 files changed, 65 insertions(+), 25 deletions(-) diff --git a/src/client/__tests__/token-store.test.ts b/src/client/__tests__/token-store.test.ts index a94d5a0..a4b0753 100644 --- a/src/client/__tests__/token-store.test.ts +++ b/src/client/__tests__/token-store.test.ts @@ -67,6 +67,20 @@ describe("token-store", () => { expect(runtime.setCache).toHaveBeenCalled(); expect(runtime.getCache).toHaveBeenCalled(); }); + + it("clear removes the cached value (via undefined)", async () => { + const store = new RuntimeCacheTokenStore(runtime); + await store.save({ + access_token: "a", + refresh_token: "r", + expires_at: 123, + }); + + await store.clear(); + const loaded = await store.load(); + expect(loaded).toBeNull(); + expect(runtime.setCache).toHaveBeenCalledWith(expect.any(String), undefined); + }); }); }); diff --git a/src/client/auth-providers/interactive.ts b/src/client/auth-providers/interactive.ts index ab9973e..6771ca5 100644 --- a/src/client/auth-providers/interactive.ts +++ b/src/client/auth-providers/interactive.ts @@ -53,6 +53,21 @@ export async function waitForLoopbackCallback( const path = url.pathname || "/"; return await new Promise((resolve, reject) => { + let settled = false; + + const finish = (err?: Error, value?: OAuthCallbackResult) => { + if (settled) return; + settled = true; + if (err) reject(err); + else if (value) resolve(value); + else reject(new Error("OAuth callback finished without result")); + try { + server.close(); + } catch { + // ignore + } + }; + const server = createServer((req, res) => { try { const reqUrl = new URL(req.url ?? "", `http://${url.hostname}:${port}`); @@ -70,42 +85,34 @@ export async function waitForLoopbackCallback( if (error) { res.writeHead(400, { "content-type": "text/plain" }); res.end(`OAuth error: ${error}${errorDesc ? ` - ${errorDesc}` : ""}`); - reject(new Error(`OAuth error: ${error}${errorDesc ? ` - ${errorDesc}` : ""}`)); - server.close(); + finish(new Error(`OAuth error: ${error}${errorDesc ? ` - ${errorDesc}` : ""}`)); return; } if (!code) { res.writeHead(400, { "content-type": "text/plain" }); res.end("Missing code"); + finish(new Error("Missing code")); return; } if (state && state !== expectedState) { res.writeHead(400, { "content-type": "text/plain" }); res.end("State mismatch"); - reject(new Error("OAuth state mismatch")); - server.close(); + finish(new Error("OAuth state mismatch")); return; } res.writeHead(200, { "content-type": "text/plain" }); res.end("Twitter auth completed. You can close this tab."); - resolve({ code, state }); - server.close(); + finish(undefined, { code, state }); } catch (e) { - reject(e instanceof Error ? e : new Error(String(e))); - server.close(); + finish(e instanceof Error ? e : new Error(String(e))); } }); const timer = setTimeout(() => { - reject(new Error("Timed out waiting for Twitter OAuth callback")); - try { - server.close(); - } catch { - // ignore - } + finish(new Error("Timed out waiting for Twitter OAuth callback")); }, timeoutMs); server.on("close", () => clearTimeout(timer)); diff --git a/src/client/auth-providers/token-store.ts b/src/client/auth-providers/token-store.ts index f6d8f18..60f4c89 100644 --- a/src/client/auth-providers/token-store.ts +++ b/src/client/auth-providers/token-store.ts @@ -38,7 +38,9 @@ export class RuntimeCacheTokenStore implements TokenStore { } async clear(): Promise { - await this.runtime.setCache(this.key, null as any); + // Prefer deleting semantics without relying on null (some runtimes/types disallow null). + // If the runtime doesn't support true deletion, setting `undefined` should be treated as "not set". + await this.runtime.setCache(this.key, undefined as any); } } @@ -64,8 +66,18 @@ export class FileTokenStore implements TokenStore { } async save(tokens: StoredOAuth2Tokens): Promise { - await fs.mkdir(dirname(this.path), { recursive: true }); - await fs.writeFile(this.path, JSON.stringify(tokens, null, 2), "utf-8"); + // Ensure token directory + file are owner-only (defense-in-depth for shared machines). + await fs.mkdir(dirname(this.path), { recursive: true, mode: 0o700 }); + await fs.writeFile(this.path, JSON.stringify(tokens, null, 2), { + encoding: "utf-8", + mode: 0o600, + }); + // Some platforms ignore mode on write when file already exists; enforce explicitly. + try { + await fs.chmod(this.path, 0o600); + } catch { + // ignore + } } async clear(): Promise { diff --git a/src/services/PostService.ts b/src/services/PostService.ts index fa555bf..1bfab6b 100644 --- a/src/services/PostService.ts +++ b/src/services/PostService.ts @@ -63,14 +63,21 @@ export class TwitterPostService implements IPostService { // TODO: Add media support when available ); - const tweetId = - (await this.extractTweetId(result)) || - (() => { - logger.warn( - "Twitter createPost: could not extract tweet id from API result; falling back to timestamp id", - ); - return Date.now().toString(); - })(); + const tweetId = await this.extractTweetId(result); + if (!tweetId) { + const safeResult = + typeof result === "string" + ? result + : JSON.stringify(result, null, 2).slice(0, 8000); + logger.error( + "Twitter createPost: could not extract tweet id from API result", + { inReplyTo: options.inReplyTo, textLength: options.text?.length }, + safeResult, + ); + throw new Error( + "Twitter createPost failed: could not extract tweet id from API response. See logs for raw response.", + ); + } const post: Post = { id: tweetId, From 0e436da1ec8e5ab611937d5574e019749b0e87f8 Mon Sep 17 00:00:00 2001 From: Vivek Kotecha Date: Wed, 31 Dec 2025 17:54:47 +0530 Subject: [PATCH 3/3] fix: harden oauth callback + avoid Response body consumption --- src/client/auth-providers/interactive.ts | 5 +++ src/services/PostService.ts | 43 ++++++++++++++++------ src/services/__tests__/PostService.test.ts | 24 ++++++++++++ 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/client/auth-providers/interactive.ts b/src/client/auth-providers/interactive.ts index 6771ca5..515b15b 100644 --- a/src/client/auth-providers/interactive.ts +++ b/src/client/auth-providers/interactive.ts @@ -116,6 +116,11 @@ export async function waitForLoopbackCallback( }, timeoutMs); server.on("close", () => clearTimeout(timer)); + server.once("error", (err: any) => { + // EADDRINUSE / EACCES / etc. should fail fast instead of hanging until timeout. + const code = err?.code ? ` (${err.code})` : ""; + finish(new Error(`OAuth callback server error${code}: ${err?.message ?? String(err)}`)); + }); server.listen(port, url.hostname, () => { logger.info( `Twitter OAuth callback server listening on http://${url.hostname}:${port}${path}`, diff --git a/src/services/PostService.ts b/src/services/PostService.ts index 1bfab6b..4f1ec78 100644 --- a/src/services/PostService.ts +++ b/src/services/PostService.ts @@ -12,6 +12,29 @@ import { SearchMode } from "../client"; export class TwitterPostService implements IPostService { constructor(private client: ClientBase) {} + private async safeParseJsonResponse(result: any): Promise { + try { + // If this is a real Fetch Response, avoid consuming the original body. + if (result?.clone && typeof result.clone === "function") { + // If body is already used, clone() may throw; guard defensively. + if (result?.bodyUsed === true) return undefined; + const cloned = result.clone(); + if (cloned?.json && typeof cloned.json === "function") { + return await cloned.json(); + } + return undefined; + } + + // Non-Response shapes (e.g. our internal wrappers) may expose json() but do not consume streams. + if (result?.json && typeof result.json === "function") { + return await result.json(); + } + return undefined; + } catch { + return undefined; + } + } + private extractRestId(result: any): string | undefined { return ( result?.rest_id ?? @@ -30,18 +53,14 @@ export class TwitterPostService implements IPostService { // Some callers return a Response-like shape with a json() function. if (result?.json && typeof result.json === "function") { - try { - const body = await result.json(); - return ( - body?.id ?? - body?.data?.id ?? - body?.data?.data?.id ?? - this.extractRestId(body) ?? - undefined - ); - } catch { - return undefined; - } + const body = await this.safeParseJsonResponse(result); + return ( + body?.id ?? + body?.data?.id ?? + body?.data?.data?.id ?? + this.extractRestId(body) ?? + undefined + ); } return undefined; diff --git a/src/services/__tests__/PostService.test.ts b/src/services/__tests__/PostService.test.ts index f674046..70aad63 100644 --- a/src/services/__tests__/PostService.test.ts +++ b/src/services/__tests__/PostService.test.ts @@ -94,6 +94,30 @@ describe("TwitterPostService", () => { }); }); + it("should not consume a Response-like body when extracting tweet id (uses clone)", async () => { + const body = { id: "tweet-999" }; + const responseLike: any = { + bodyUsed: false, + clone: vi.fn(() => ({ + json: vi.fn(async () => body), + })), + json: vi.fn(async () => body), // would consume in real Response; here we ensure clone() path is used + }; + + mockClient.twitterClient.sendTweet.mockResolvedValue(responseLike); + + const options = { + agentId: "agent-123" as any, + roomId: "room-123" as any, + text: "Hello World!", + }; + + const post = await service.createPost(options); + expect(post.id).toBe("tweet-999"); + expect(responseLike.clone).toHaveBeenCalled(); + expect(responseLike.json).not.toHaveBeenCalled(); + }); + it("should create a reply post", async () => { const mockResult = { id: "tweet-456" };