Skip to content

Commit a80cf99

Browse files
authored
Add API v2 user email retrieval support (#593)
* Add user email request feature to API v2 * Add OAuth 2.0 flow test and test setup script * Fix wrong file name in OAuth 2 setup script
1 parent 1defe1a commit a80cf99

File tree

8 files changed

+243
-3
lines changed

8 files changed

+243
-3
lines changed

doc/auth.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,8 @@ To create the authentication link, use `client.generateOAuth2AuthLink()` method.
191191
**You need to provide a callback URL here.**
192192
```ts
193193
// Don't forget to specify 'offline.access' in scope list if you want to refresh your token later
194-
const { url, codeVerifier, state } = client.generateOAuth2AuthLink(CALLBACK_URL, { scope: ['tweet.read', 'users.read', 'offline.access', ...] });
194+
// Include 'users.email' if you need to access user's email address
195+
const { url, codeVerifier, state } = client.generateOAuth2AuthLink(CALLBACK_URL, { scope: ['tweet.read', 'users.read', 'users.email', 'offline.access', ...] });
195196

196197
// Redirect your user to {url}, store {state} and {codeVerifier} into a DB/Redis/memory after user redirection
197198
```

doc/v2.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,10 @@ Get the logged user.
673673
**Example**
674674
```ts
675675
const meUser = await client.v2.me({ expansions: ['pinned_tweet_id'] });
676+
677+
// Request user's email (requires users.email OAuth 2.0 scope)
678+
const meUserWithEmail = await client.v2.me({ 'user.fields': ['confirmed_email'] });
679+
console.log(meUserWithEmail.data.confirmed_email); // [email protected]
676680
```
677681

678682
### Single user

setup-oauth2.mjs

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { TwitterApi } from './dist/cjs/index.js';
2+
import 'dotenv/config';
3+
4+
console.log('🔐 TWITTER OAUTH 2.0 SETUP FOR TESTING');
5+
console.log('======================================');
6+
7+
// Check if this is the token exchange step
8+
const command = process.argv[2];
9+
const authCode = process.argv[3];
10+
const codeVerifier = process.argv[4];
11+
12+
if (command === 'exchange' && authCode && codeVerifier) {
13+
// Step 2: Exchange authorization code for access token
14+
exchangeCodeForToken(authCode, codeVerifier);
15+
} else {
16+
// Step 1: Generate authorization URL
17+
generateAuthorizationUrl();
18+
}
19+
20+
function generateAuthorizationUrl() {
21+
console.log('\n📋 PREREQUISITES:');
22+
console.log('1. Twitter Developer Account with an app created');
23+
console.log('2. App configured as "Web App" (not Native App)');
24+
console.log('3. CLIENT_ID, CLIENT_SECRET, and CALLBACK_URL in your .env file');
25+
console.log('4. Same callback URL added to your Twitter app settings');
26+
27+
// Check credentials - all required
28+
if (!process.env.CLIENT_ID || !process.env.CLIENT_SECRET || !process.env.CALLBACK_URL) {
29+
console.log('\n❌ ERROR: Missing required environment variables');
30+
console.log('\nCreate a .env file with:');
31+
console.log('CLIENT_ID=your_oauth2_client_id');
32+
console.log('CLIENT_SECRET=your_oauth2_client_secret');
33+
console.log('CALLBACK_URL=your_callback_url');
34+
console.log('\nThen add the same callback URL to your Twitter app settings!');
35+
return;
36+
}
37+
38+
const callbackUrl = process.env.CALLBACK_URL;
39+
40+
try {
41+
const client = new TwitterApi({
42+
clientId: process.env.CLIENT_ID,
43+
clientSecret: process.env.CLIENT_SECRET,
44+
});
45+
46+
const { url, codeVerifier } = client.generateOAuth2AuthLink(
47+
callbackUrl,
48+
{ scope: ['tweet.read', 'users.read', 'users.email', 'offline.access'] }
49+
);
50+
51+
console.log('\n🔗 STEP 1: Visit this authorization URL:');
52+
console.log('─'.repeat(60));
53+
console.log(url);
54+
console.log('─'.repeat(60));
55+
56+
console.log('\n👆 What will happen:');
57+
console.log('• You\'ll be redirected to Twitter to sign in');
58+
console.log('• Twitter will ask you to authorize the app');
59+
console.log(`• You'll be redirected to: ${callbackUrl}?code=...`);
60+
console.log('• The page will show "This site can\'t be reached" - that\'s OK!');
61+
62+
console.log('\n⚡ STEP 2: After authorization, IMMEDIATELY run:');
63+
console.log(`node setup-oauth2.mjs exchange YOUR_CODE "${codeVerifier}"`);
64+
65+
console.log('\n⚠️ IMPORTANT:');
66+
console.log('• Copy the "code" parameter from the redirect URL');
67+
console.log('• Run the exchange command IMMEDIATELY (codes expire in ~30 seconds)');
68+
console.log('• Don\'t include the full URL, just the code parameter');
69+
70+
console.log('\n📧 SCOPES INCLUDED:');
71+
console.log('• tweet.read - Read tweets');
72+
console.log('• users.read - Read user information');
73+
console.log('• users.email - Access confirmed email (key feature!)');
74+
console.log('• offline.access - Get refresh token');
75+
76+
} catch (error) {
77+
console.log('\n❌ ERROR:', error.message);
78+
}
79+
}
80+
81+
async function exchangeCodeForToken(authCode, codeVerifier) {
82+
console.log('\n⚡ EXCHANGING CODE FOR ACCESS TOKEN...');
83+
84+
// Check that CALLBACK_URL is set
85+
if (!process.env.CALLBACK_URL) {
86+
console.log('\n❌ ERROR: CALLBACK_URL is required in .env file');
87+
return;
88+
}
89+
90+
const callbackUrl = process.env.CALLBACK_URL;
91+
92+
const client = new TwitterApi({
93+
clientId: process.env.CLIENT_ID,
94+
clientSecret: process.env.CLIENT_SECRET,
95+
});
96+
97+
try {
98+
const startTime = Date.now();
99+
100+
const { client: loggedClient, accessToken, refreshToken, expiresIn } =
101+
await client.loginWithOAuth2({
102+
code: authCode,
103+
codeVerifier,
104+
redirectUri: callbackUrl,
105+
});
106+
107+
const duration = Date.now() - startTime;
108+
109+
console.log(`\n🎉 SUCCESS! (${duration}ms)`);
110+
console.log('═'.repeat(50));
111+
112+
// Test the token immediately
113+
console.log('\n🧪 Testing access...');
114+
const user = await loggedClient.v2.me({
115+
'user.fields': ['verified', 'verified_type', 'confirmed_email'],
116+
});
117+
118+
console.log(`✅ Authenticated as: @${user.data.username}`);
119+
console.log(`✅ Verified: ${user.data.verified} (${user.data.verified_type || 'none'})`);
120+
121+
if (user.data.confirmed_email) {
122+
console.log(`✅ Email access: ${user.data.confirmed_email}`);
123+
} else {
124+
console.log('ℹ️ Email not returned (check app permissions)');
125+
}
126+
127+
console.log('\n📋 ADD TO YOUR .env FILE:');
128+
console.log('─'.repeat(40));
129+
console.log(`OAUTH2_ACCESS_TOKEN=${accessToken}`);
130+
if (refreshToken) {
131+
console.log(`OAUTH2_REFRESH_TOKEN=${refreshToken}`);
132+
}
133+
console.log('─'.repeat(40));
134+
135+
console.log(`\n⏰ Token expires in: ${Math.round(expiresIn / 3600)} hours`);
136+
137+
console.log('\n🚀 NEXT STEPS:');
138+
console.log('1. Add the tokens to your .env file');
139+
console.log('2. Run your OAuth 2.0 tests: npm run mocha \'test/oauth2.user.v2.test.ts\'');
140+
console.log('3. Start using OAuth 2.0 with email access in your app!');
141+
142+
} catch (error) {
143+
console.log('\n❌ TOKEN EXCHANGE FAILED:', error.message);
144+
145+
if (error.message.includes('authorization code')) {
146+
console.log('\n💡 COMMON ISSUES:');
147+
console.log('• Code expired (they expire in ~30 seconds)');
148+
console.log('• Code already used (each code works only once)');
149+
console.log('• Wrong code copied');
150+
console.log('\n🔄 Try again: node setup-oauth2.mjs');
151+
}
152+
}
153+
}

src/test/utils.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,28 @@ export function getAppClient() {
7474
return requestClient.appLogin();
7575
}
7676
}
77+
78+
/** OAuth 2.0 user-context client for testing features requiring user scopes (like email access) */
79+
export function getOAuth2UserClient() {
80+
if (!process.env.OAUTH2_ACCESS_TOKEN) {
81+
throw new Error('OAUTH2_ACCESS_TOKEN environment variable is required for OAuth 2.0 user-context authentication');
82+
}
83+
84+
return new TwitterApi(process.env.OAUTH2_ACCESS_TOKEN);
85+
}
86+
87+
/** Get OAuth 2.0 client for generating auth links (requires CLIENT_ID and CLIENT_SECRET) */
88+
export function getOAuth2RequestClient() {
89+
return new TwitterApi({
90+
clientId: process.env.CLIENT_ID!,
91+
clientSecret: process.env.CLIENT_SECRET!,
92+
});
93+
}
94+
95+
/** Generate OAuth 2.0 auth link with email scope for testing */
96+
export function getOAuth2AuthLink(callback: string) {
97+
const client = getOAuth2RequestClient();
98+
return client.generateOAuth2AuthLink(callback, {
99+
scope: ['tweet.read', 'users.read', 'users.email', 'follows.read', 'offline.access'],
100+
});
101+
}

src/types/auth.types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type TwitterApi from '../client';
22
import { TypeOrArrayOf } from './shared.types';
33

4-
export type TOAuth2Scope = 'tweet.read' | 'tweet.write' | 'tweet.moderate.write' | 'users.read' | 'follows.read' | 'follows.write'
4+
export type TOAuth2Scope = 'tweet.read' | 'tweet.write' | 'tweet.moderate.write' | 'users.read' | 'users.email' | 'follows.read' | 'follows.write'
55
| 'offline.access' | 'space.read' | 'mute.read' | 'mute.write' | 'like.read' | 'like.write' | 'list.read' | 'list.write'
66
| 'block.read' | 'block.write' | 'bookmark.read' | 'bookmark.write' | 'dm.read' | 'dm.write';
77

src/types/v2/tweet.v2.types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export type TTweetv2TweetField = 'article' | 'attachments' | 'author_id' | 'cont
5353
| 'created_at' | 'entities' | 'geo' | 'id' | 'in_reply_to_user_id' | 'lang'
5454
| 'public_metrics' | 'non_public_metrics' | 'promoted_metrics' | 'organic_metrics' | 'edit_controls'
5555
| 'possibly_sensitive' | 'referenced_tweets' | 'reply_settings' | 'source' | 'text' | 'withheld' | 'note_tweet' | 'edit_history_tweet_ids';
56-
export type TTweetv2UserField = 'created_at' | 'description' | 'entities' | 'id' | 'location'
56+
export type TTweetv2UserField = 'created_at' | 'description' | 'confirmed_email' | 'entities' | 'id' | 'location'
5757
| 'name' | 'pinned_tweet_id' | 'profile_image_url' | 'profile_banner_url' | 'protected' | 'public_metrics'
5858
| 'url' | 'username' | 'verified' | 'verified_type' | 'withheld' | 'connection_status' | 'most_recent_tweet_id';
5959

src/types/v2/user.v2.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export interface UserV2 {
9999
description?: string;
100100
verified?: boolean;
101101
verified_type?: 'none' | 'blue' | 'business' | 'government';
102+
confirmed_email?: string;
102103
entities?: {
103104
url?: { urls: UrlEntity[] };
104105
description: {

test/oauth2.user.v2.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import 'mocha';
2+
import { expect } from 'chai';
3+
import { TwitterApi } from '../src';
4+
import { getOAuth2UserClient } from '../src/test/utils';
5+
6+
let oauth2Client: TwitterApi;
7+
8+
describe('OAuth 2.0 User-Context v2 API Tests', () => {
9+
before(async () => {
10+
try {
11+
oauth2Client = getOAuth2UserClient();
12+
} catch (error) {
13+
console.warn('OAuth 2.0 tests skipped: Missing OAUTH2_ACCESS_TOKEN environment variable');
14+
console.warn('Run setup-oauth2.mjs to set up the OAuth 2.0 flow');
15+
return;
16+
}
17+
});
18+
19+
it('should get current user with email access', async function () {
20+
if (!oauth2Client) {
21+
this.skip();
22+
}
23+
24+
const user = await oauth2Client.v2.me({
25+
'user.fields': [
26+
'id',
27+
'username',
28+
'name',
29+
'verified',
30+
'verified_type',
31+
'created_at',
32+
'description',
33+
'public_metrics',
34+
'profile_image_url',
35+
'confirmed_email',
36+
],
37+
});
38+
39+
expect(user.data).to.be.an('object');
40+
expect(user.data.id).to.be.a('string');
41+
expect(user.data.username).to.be.a('string');
42+
expect(user.data.name).to.be.a('string');
43+
44+
if (user.data.verified !== undefined) {
45+
expect(user.data.verified).to.be.a('boolean');
46+
}
47+
48+
if (user.data.verified_type !== undefined) {
49+
expect(['blue', 'business', 'government', 'none']).to.include(user.data.verified_type);
50+
}
51+
52+
if (user.data.confirmed_email !== undefined) {
53+
expect(user.data.confirmed_email).to.be.a('string');
54+
}
55+
});
56+
});

0 commit comments

Comments
 (0)