Skip to content

Commit

Permalink
Uses oauth2 (#1)
Browse files Browse the repository at this point in the history
* convert oauth2

* implement oauth2

* fmt
  • Loading branch information
elliotBraem authored Jan 10, 2025
1 parent e7a44ad commit 4413404
Show file tree
Hide file tree
Showing 8 changed files with 56 additions and 108 deletions.
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Twitter OAuth 2.0 credentials
TWITTER_CLIENT_ID=your_oauth2_client_id
TWITTER_CLIENT_SECRET=your_oauth2_client_secret

# Base URL for OAuth callback
NEXT_PUBLIC_BASE_URL=http://localhost:3000
2 changes: 1 addition & 1 deletion src/components/connect-to-twitter.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function ConnectToTwitterButton() {
}, [isConnected, connect, disconnect]);

return (
<Button onClick={handleClick} disabled={true}>
<Button onClick={handleClick} disabled={false}>
<Twitter size={18} />
{isConnecting
? "Connecting..."
Expand Down
1 change: 0 additions & 1 deletion src/lib/twitter.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ export function getTwitterTokens() {

return {
accessToken: cookies.twitter_access_token,
accessSecret: cookies.twitter_access_secret,
};
}

Expand Down
18 changes: 10 additions & 8 deletions src/pages/api/twitter/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ export default async function handler(req, res) {
if (req.method === "DELETE") {
res.setHeader("Set-Cookie", [
"twitter_access_token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; HttpOnly; SameSite=Lax",
"twitter_access_secret=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; HttpOnly; SameSite=Lax",
"oauth_token_secret=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; HttpOnly; SameSite=Lax",
"twitter_refresh_token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; HttpOnly; SameSite=Lax",
"code_verifier=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; HttpOnly; SameSite=Lax",
"oauth_state=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; HttpOnly; SameSite=Lax",
]);
return res.status(200).json({ message: "Logged out successfully" });
}
Expand All @@ -14,21 +15,22 @@ export default async function handler(req, res) {
return res.status(405).json({ error: "Method not allowed" });
}

if (!process.env.TWITTER_API_KEY || !process.env.TWITTER_API_SECRET) {
if (!process.env.TWITTER_CLIENT_ID || !process.env.TWITTER_CLIENT_SECRET) {
return res
.status(500)
.json({ error: "Twitter API credentials are missing" });
.json({ error: "Twitter OAuth 2.0 credentials are missing" });
}

try {
const twitterService = await TwitterService.initialize();
const callbackUrl = `${process.env.NEXT_PUBLIC_BASE_URL}/api/twitter/callback`;
const authData = await twitterService.getAuthLink(callbackUrl);

res.setHeader(
"Set-Cookie",
`oauth_token_secret=${authData.oauth_token_secret}; Path=/; HttpOnly; SameSite=Lax`,
);
// Store PKCE and state values in cookies
res.setHeader("Set-Cookie", [
`code_verifier=${authData.codeVerifier}; Path=/; HttpOnly; SameSite=Lax`,
`oauth_state=${authData.state}; Path=/; HttpOnly; SameSite=Lax`,
]);
res.status(200).json({ authUrl: authData.url });
} catch (error) {
console.error("Twitter auth error:", error);
Expand Down
25 changes: 16 additions & 9 deletions src/pages/api/twitter/callback.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,33 @@ export default async function handler(req, res) {
return res.status(405).json({ error: "Method not allowed" });
}

const { oauth_token, oauth_verifier } = req.query;
const { code, state } = req.query;
const cookies = parse(req.headers.cookie || "");
const oauth_token_secret = cookies.oauth_token_secret;
const { code_verifier, oauth_state } = cookies;

if (!oauth_token || !oauth_verifier || !oauth_token_secret) {
if (!code || !state || !code_verifier || !oauth_state) {
return res.status(400).json({ error: "Missing OAuth parameters" });
}

// Verify the state parameter to prevent CSRF attacks
if (state !== oauth_state) {
return res.status(400).json({ error: "Invalid OAuth state" });
}

try {
const twitterService = await TwitterService.initialize();
const { accessToken, accessSecret } = await twitterService.handleCallback(
oauth_token,
oauth_verifier,
oauth_token_secret,
const { accessToken, refreshToken } = await twitterService.handleCallback(
code,
code_verifier,
state,
);

// Store tokens in HttpOnly cookies
res.setHeader("Set-Cookie", [
`twitter_access_token=${accessToken}; Path=/; HttpOnly; SameSite=Lax`,
`twitter_access_secret=${accessSecret}; Path=/; HttpOnly; SameSite=Lax`,
"oauth_token_secret=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
`twitter_refresh_token=${refreshToken}; Path=/; HttpOnly; SameSite=Lax`,
"code_verifier=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
"oauth_state=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
]);

// Clean redirect since we're using Zustand store
Expand Down
47 changes: 2 additions & 45 deletions src/pages/api/twitter/status.js
Original file line number Diff line number Diff line change
@@ -1,65 +1,22 @@
import { parse } from "cookie";
import { TwitterService } from "../../../services/twitter";

const cache = new Map();
const CACHE_EXPIRY = 5 * 60 * 1000; // 5 minutes in milliseconds

const getCacheKey = (accessToken, accessSecret) =>
`${accessToken}:${accessSecret}`;

const getCachedUserInfo = (accessToken, accessSecret) => {
const key = getCacheKey(accessToken, accessSecret);
const cached = cache.get(key);
if (!cached) return null;

// Check if cache has expired
if (Date.now() - cached.timestamp > CACHE_EXPIRY) {
cache.delete(key);
return null;
}

return cached.data;
};

const setCachedUserInfo = (accessToken, accessSecret, userInfo) => {
const key = getCacheKey(accessToken, accessSecret);
cache.set(key, {
data: userInfo,
timestamp: Date.now(),
});
};

export default async function handler(req, res) {
if (req.method !== "GET") {
return res.status(405).json({ error: "Method not allowed" });
}

const cookies = parse(req.headers.cookie || "");
const accessToken = cookies.twitter_access_token;
const accessSecret = cookies.twitter_access_secret;
const isConnected = !!(accessToken && accessSecret);
const isConnected = !!accessToken;

if (!isConnected) {
return res.json({ isConnected, handle: null });
}

try {
// Check cache first
const cachedInfo = getCachedUserInfo(accessToken, accessSecret);
if (cachedInfo) {
return res.json({ isConnected, handle: cachedInfo.username });
}

// If not in cache, fetch from Twitter
const twitterService = await TwitterService.initialize();
const userInfo = await twitterService.getUserInfo(
accessToken,
accessSecret,
);

// Cache the result
setCachedUserInfo(accessToken, accessSecret, userInfo);

const userInfo = await twitterService.getUserInfo(accessToken);
res.json({ isConnected, handle: userInfo.username });
} catch (error) {
console.error("Failed to fetch user info:", error);
Expand Down
9 changes: 2 additions & 7 deletions src/pages/api/twitter/tweet.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,13 @@ export default async function handler(req, res) {

const cookies = parse(req.headers.cookie || "");
const accessToken = cookies.twitter_access_token;
const accessSecret = cookies.twitter_access_secret;
if (!accessToken || !accessSecret) {
if (!accessToken) {
return res.status(401).json({ error: "Not authenticated with Twitter" });
}

try {
const twitterService = await TwitterService.initialize();
const response = await twitterService.tweet(
accessToken,
accessSecret,
posts,
);
const response = await twitterService.tweet(accessToken, posts);
res.status(200).json({
success: true,
data: Array.isArray(response) ? response : [response],
Expand Down
56 changes: 19 additions & 37 deletions src/services/twitter.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,55 +4,42 @@ import { TwitterApi } from "twitter-api-v2";
// which manages API credentials and handles OAuth communciation with Twitter

export class TwitterService {
constructor(clientId, clientSecret, apiKey, apiSecret) {
if (!clientId || !clientSecret || !apiKey || !apiSecret) {
throw new Error("Twitter API credentials are required");
constructor(clientId, clientSecret) {
if (!clientId || !clientSecret) {
throw new Error("Twitter OAuth 2.0 credentials are required");
}
this.client = new TwitterApi({
clientId: clientId,
clientSecret: clientSecret,
appKey: apiKey,
appSecret: apiSecret,
});
this.apiKey = apiKey;
this.apiSecret = apiSecret;
}

static async initialize() {
const clientId = process.env.TWITTER_CLIENT_ID;
const clientSecret = process.env.TWITTER_CLIENT_SECRET;
const apiKey = process.env.TWITTER_API_KEY;
const apiSecret = process.env.TWITTER_API_SECRET;

return new TwitterService(clientId, clientSecret, apiKey, apiSecret);
return new TwitterService(clientId, clientSecret);
}

async getAuthLink(callbackUrl) {
const { url, oauth_token, oauth_token_secret } =
await this.client.generateAuthLink(callbackUrl, {
scope: ["tweet.read", "tweet.write", "users.read"],
});
return { url, oauth_token, oauth_token_secret };
// Use OAuth 2.0 with PKCE for more granular scope control
const { url, codeVerifier, state } = this.client.generateOAuth2AuthLink(
callbackUrl,
{ scope: ["tweet.read", "tweet.write", "users.read"] },
);
return { url, codeVerifier, state };
}

async handleCallback(oauthToken, oauthVerifier, oauthTokenSecret) {
const tempClient = new TwitterApi({
appKey: this.apiKey,
appSecret: this.apiSecret,
accessToken: oauthToken,
accessSecret: oauthTokenSecret,
async handleCallback(code, codeVerifier, state) {
return this.client.loginWithOAuth2({
code,
codeVerifier,
redirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}/api/twitter/callback`,
});

return tempClient.login(oauthVerifier);
}

async tweet(accessToken, accessSecret, posts) {
const userClient = new TwitterApi({
appKey: this.apiKey,
appSecret: this.apiSecret,
accessToken,
accessSecret,
});
async tweet(accessToken, posts) {
const userClient = new TwitterApi(accessToken);

// Handle array of post objects
if (!Array.isArray(posts)) {
Expand Down Expand Up @@ -81,13 +68,8 @@ export class TwitterService {
}
}

async getUserInfo(accessToken, accessSecret) {
const userClient = new TwitterApi({
appKey: this.apiKey,
appSecret: this.apiSecret,
accessToken,
accessSecret,
});
async getUserInfo(accessToken) {
const userClient = new TwitterApi(accessToken);

const me = await userClient.v2.me();
return me.data;
Expand Down

0 comments on commit 4413404

Please sign in to comment.