Skip to content

Commit

Permalink
Merge pull request #127 from PLhery/feat/oauth2-user-out-of-beta
Browse files Browse the repository at this point in the history
Feat: Add OAuth2 user context
  • Loading branch information
alkihis authored Dec 19, 2021
2 parents a2170be + 4ba00e2 commit c8dacc7
Show file tree
Hide file tree
Showing 17 changed files with 202 additions and 44 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Here's the detailed feature list of `twitter-api-v2`:

### Basics:
- Support for v1.1 and **v2 of Twitter API**
- Make signed HTTP requests to Twitter with every auth type: **OAuth 1.0a**, **OAuth2** and **Basic** HTTP Authorization
- Make signed HTTP requests to Twitter with every auth type: **OAuth 1.0a**, **OAuth2** (even brand new user context OAuth2!) and **Basic** HTTP Authorization
- Helpers for numerous HTTP request methods (`GET`, `POST`, `PUT`, `DELETE` and `PATCH`),
that handle query string parse & format, automatic body formatting and more
- High-class support for stream endpoints, with easy data consumption and auto-reconnect on stream errors
Expand Down Expand Up @@ -107,7 +107,7 @@ Learn how to use the full potential of `twitter-api-v2`.

- Get started
- [Create a client and make your first request](./doc/basics.md)
- [Handle Twitter authentification flows](./doc/auth.md)
- [Handle Twitter authentication flows](./doc/auth.md)
- [Explore some examples](./doc/examples.md)
- Use endpoints wrappers — ensure typings of request & response
- [Available endpoint wrappers for v1.1 API](./doc/v1.md)
Expand Down
2 changes: 1 addition & 1 deletion changelog.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
1.7.2
-----
- Fix: Paginator can return multiple times the same results in some conditions
- Feat: .done properties for paginators, to know when a next page is fetchable
- Feat: .done property for paginators, to know when a next page is fetchable

1.7.1
-----
Expand Down
125 changes: 111 additions & 14 deletions doc/auth.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
# Authentification
# Authentication

This part will guide you through the multiple steps of Twitter API authentification process
This part will guide you through the multiple steps of Twitter API authentication process
inside `twitter-api-v2` package.

Please first see the [Basics](./basics.md) to know how to create a client with your application keys.

***First***, you must know which type of authentification you want to use.
***First***, you must know which type of authentication you want to use.

- User authentification (3-legged OAuth 1.0a flow, see [User-wide authentification flow](#user-wide-authentification-flow))
- App-only authentification (Bearer token, see [Application-only authentification flow](#application-only-authentification-flow))
- Basic authentification (couple of username+password, see [Basic authentification flow](#basic-authentification-flow))
- User authentication (3-legged OAuth 1.0a flow, see [User-wide authentication flow](#user-wide-authentication-flow))
- App-only authentication (Bearer token, see [Application-only authentication flow](#application-only-authentication-flow))
- Basic authentication (couple of username+password, see [Basic authentication flow](#basic-authentication-flow))
- User authentication, but with fine-grained scopes (3-legged OAuth 2 flow, see [User-wide authentication flow for OAuth2](#oauth2-user-wide-authentication-flow))

**Note**: You can find a [project real-life example of a 3-legged auth flow here](https://github.com/alkihis/twitter-api-v2-user-oauth-flow-example).

## User-wide authentification flow
## User-wide authentication flow

Many endpoints on the Twitter developer platform use the OAuth 1.0a method to act on behalf of a Twitter account.
For example, if you have a Twitter developer app, you can make API requests on behalf of any Twitter account as long as that user authenticates your app.

This method is **fairly the most complex** of authentification flow options, but it is, at least for now, the **most used method across Twitter API**.
This method is **fairly the most complex** of authentication flow options, but it is, at least for now, the **most used method across Twitter API**.

It is named "3-legged" because it is splitted in 3 parts:
1. You (the app/server) generate a auth link that is clickable by a external user, and gives you *temporary* access tokens
Expand All @@ -40,8 +41,8 @@ You need to have a client instantiated with your **consumer keys** from Twitter.
const client = new TwitterApi({ appKey: CONSUMER_KEY, appSecret: CONSUMER_SECRET });
```

To create the authentification link, use `client.generateAuthLink()` method.
**If you choose to redirect users to your website after authentification, you need to provide a callback URL here.**
To create the authentication link, use `client.generateAuthLink()` method.
**If you choose to redirect users to your website after authentication, you need to provide a callback URL here.**
```ts
const authLink = await client.generateAuthLink(CALLBACK_URL);

Expand Down Expand Up @@ -92,7 +93,7 @@ app.get('/callback', (req, res) => {

client.login(oauth_verifier)
.then(({ client: loggedClient, accessToken, accessSecret }) => {
// loggedClient is an authentificated client in behalf of some user
// loggedClient is an authenticated client in behalf of some user
// Store accessToken & accessSecret somewhere
})
.catch(() => res.status(403).send('Invalid verifier or access tokens!'));
Expand All @@ -118,7 +119,7 @@ const client = new TwitterApi({

// Give the PIN to client.login()
const { client: loggedClient, accessToken, accessSecret } = await client.login(GIVEN_USER_PIN);
// loggedClient is an authentificated client in behalf of some user
// loggedClient is an authenticated client in behalf of some user
// Store accessToken & accessSecret somewhere
```

Expand All @@ -129,7 +130,7 @@ You can use the method `.currentUser()` on your client.
This a shortcut to `.v1.verifyCredentials()` with a **cache that store user to avoid multiple API calls**.
Its returns a `UserV1` object.

## Application-only authentification flow
## Application-only authentication flow

App-only flow use a single OAuth 2.0 Bearer Token that authenticates requests on behalf of your developer App.
As this method is specific to the App, it does not involve any users.
Expand All @@ -147,7 +148,7 @@ const consumerClient = new TwitterApi({ appKey: CONSUMER_KEY, appSecret: CONSUME
const client = await consumerClient.appLogin();
```

## Basic authentification flow
## Basic authentication flow

Mainly for **Twitter enterprise APIs**, that require the use of HTTP Basic Authentication.
You must pass a valid email address and password combination for each request.
Expand All @@ -157,3 +158,99 @@ Use this combination to create your Twitter API client:
```ts
const client = new TwitterApi({ username: MY_USERNAME, password: MY_PASSWORD });
```

## OAuth2 user-wide authentication flow

Alternatively of OAuth 1.0a method, you can use OAuth2 user-context, which is restricted to **v2 of Twitter API**.
This process is very similar of one used in OAuth 1.0a, so it's recommand to read it first to understand what's happening below.

The main advantage of this method is that you can **explicitly specify which part of data you'll need from the Twitter user's account**.
These parts are called **scopes**.

This authentification is splitted into 3 parts:
1. You (the app/server) generate a auth link with your client ID that is clickable by an external user
2. The user clicks on the link, approves the application, it gives you a client code
3. You use a code verifier generated at the first step along the client code to obtain **user-specific** access token; this token has a dedicated lifetime that can be extended with refresh tokens

**NOTE**
> - If you're building a server that serves content for users,
> you need to "remember" (store) some data between the first two steps,
> so be sure you have a available session-like store (file/memory/Redis/...) to share data across same-user requests.
> - Between steps 1 & 2, users are redirected to official Twitter website. That means you need to have a dedicated page in your website meant to "welcome back" users that have been sent to Twitter (this is called **oauth callback**)
### Create the auth link

You need to have a client instantiated with your **client keys** from Twitter.
If you've declared app as "public" app, you only need your **client ID**, if you've declared app as "confidential" app, you will need **client ID and client secret**.

```ts
const client = new TwitterApi({ clientId: CLIENT_ID, clientSecret: CLIENT_SECRET });
```

To create the authentication link, use `client.generateAuthLink()` method.
**If you choose to redirect users to your website after authentication, you need to provide a callback URL here.**
```ts
// Don't forget to specify 'offline.access' in scope list if you want to refresh your token later
const { url, codeVerifier, state } = client.generateOAuth2AuthLink(CALLBACK_URL, { scope: ['tweet.read', 'users.read', 'offline.access', ...] });

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

**IMPORTANT**: You need to store `state` and `codeVerifier` somewhere,
because you will need them for step 2.

### Collect returned auth codes and get access token

When Twitter redirects to your page, it provides two query string parameters: `code` and `state`.

**NOTE**: If the user refuses app access, `code` will not be provided.

You need to extract those tokens, find the linked `codeVerifier` from given `state` (using your session store!), then ask for accesss token.

Create a client with your **client ID** (and the **client secret** if it's needed), like at step 1.

An example flow will be written here using the **express** framework, feel free to adapt to your case.

```ts
app.get('/callback', (req, res) => {
// Exact state and code from query string
const { state, code } = req.query;
// Get the saved oauth_token_secret from session
const { codeVerifier, state: sessionState } = req.session;

if (!codeVerifier || !state || !sessionState || !code) {
return res.status(400).send('You denied the app or your session expired!');
}
if (state !== sessionState) {
return res.status(400).send('Stored tokens didnt match!');
}

// Obtain access token
const client = new TwitterApi({ clientId: CLIENT_ID, clientSecret: CLIENT_SECRET });

client.loginWithOAuth2({ code, codeVerifier, redirectUri: CALLBACK_URL })
.then(({ client: loggedClient, accessToken, refreshToken, expiresIn }) => {
// {loggedClient} is an authenticated client in behalf of some user
// Store {accessToken} somewhere, it will be valid until {expiresIn} is hit.
// If you want to refresh your token later, store {refreshToken} (it is present if 'offline.access' has been given as scope)

// Example request
const { data: userObject } = await loggedClient.v2.me();
})
.catch(() => res.status(403).send('Invalid verifier or access tokens!'));
});
```

### Optional: refresh the token later

If you choose to include `'offline.access'` as scope, you can store and re-use later `refreshToken` when `expiresIn` time kicks in.

```ts
// Obtain {refreshToken} from your DB/store
const { client: refreshedClient, accessToken, refreshToken: newRefreshToken } = await client.refreshOAuth2Token(refreshToken);

// Store refreshed {accessToken} and {newRefreshToken} to remplace the old ones

// Example request
await refreshedClient.v2.me();
```
6 changes: 3 additions & 3 deletions doc/basics.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { TwitterApi } from 'twitter-api-v2';
const { TwitterApi } = require('twitter-api-v2');
```

Instanciate with your wanted authentification method.
Instanciate with your wanted authentication method.

```ts
// OAuth 1.0a (User context)
Expand Down Expand Up @@ -57,9 +57,9 @@ you can choose the right sub-client:
- `Read-write`: `rwClient = client.readWrite`
- `Read-only`: `roClient = client.readOnly`

## Authentification
## Authentication

Please see [Authentification part](./auth.md) of the doc.
Please see [Authentication part](./auth.md) of the doc.

### Get current user

Expand Down
2 changes: 1 addition & 1 deletion doc/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ await client.v1.setWelcomeDm(welcomeDm[EDirectMessageEventTypeV1.WelcomeCreate].

You can see a [real-life example of a 3-legged auth flow here](https://github.com/alkihis/twitter-api-v2-user-oauth-flow-example).

See also [authentification documentation](./auth.md) for examples and explainations about Twitter auth flow.
See also [authentication documentation](./auth.md) for examples and explainations about Twitter auth flow.

### Generate a auth link and get access tokens

Expand Down
4 changes: 2 additions & 2 deletions doc/streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ await stream.connect({ autoReconnect: true, autoReconnectRetries: Infinity });

## <a name='SpecificAPIv1.1implementations'></a>Specific API v1.1 implementations

API v1.1 streaming-related endpoints works only with classic OAuth 1.0a authentification.
API v1.1 streaming-related endpoints works only with classic OAuth 1.0a authentication.

### <a name='Filterendpoint'></a>Filter endpoint

Expand Down Expand Up @@ -145,7 +145,7 @@ const stream = await client.v1.sampleStream();

## <a name='SpecificAPIv2implementations'></a>Specific API v2 implementations

API v2 streaming-related endpoints works only with Bearer OAuth2 authentification.
API v2 streaming-related endpoints works only with Bearer OAuth2 authentication.

### <a name='Searchendpoint'></a>Search endpoint

Expand Down
5 changes: 5 additions & 0 deletions src/client-mixins/oauth2.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ export class OAuth2Helper {
);
}

static getAuthHeader(clientId: string, clientSecret: string) {
const key = encodeURIComponent(clientId) + ':' + encodeURIComponent(clientSecret);
return Buffer.from(key).toString('base64');;
}

static generateRandomString(length: number) {
let text = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
Expand Down
6 changes: 6 additions & 0 deletions src/client-mixins/request-maker.mixin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { trimUndefinedProperties } from '../helpers';
import OAuth1Helper from './oauth1.helper';
import RequestHandlerHelper from './request-handler.helper';
import RequestParamHelpers from './request-param.helper';
import { OAuth2Helper } from './oauth2.helper';

export type TRequestFullData = {
url: URL,
Expand Down Expand Up @@ -66,6 +67,7 @@ export abstract class ClientRequestMaker {
protected _accessSecret?: string;
protected _basicToken?: string;
protected _clientId?: string;
protected _clientSecret?: string;
protected _oauth?: OAuth1Helper;
protected _rateLimits: { [endpoint: string]: TwitterRateLimit } = {};

Expand Down Expand Up @@ -165,6 +167,10 @@ export abstract class ClientRequestMaker {
// Basic auth, to request a bearer token
headers.Authorization = 'Basic ' + this._basicToken;
}
else if (this._clientId && this._clientSecret) {
// Basic auth with clientId + clientSecret
headers.Authorization = 'Basic ' + OAuth2Helper.getAuthHeader(this._clientId, this._clientSecret);
}
else if (this._consumerSecret && this._oauth) {
// Merge query and body
const data = bodyInSignature ? RequestParamHelpers.mergeQueryAndBodyForOAuth(query, body) : query;
Expand Down
32 changes: 27 additions & 5 deletions src/client.base.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { TClientTokens, TwitterApiBasicAuth, TwitterApiOAuth2Init, TwitterApiTokens, TwitterRateLimit, TwitterResponse, UserV1 } from './types';
import type { TClientTokens, TwitterApiBasicAuth, TwitterApiOAuth2Init, TwitterApiTokens, TwitterRateLimit, TwitterResponse, UserV1, UserV2Result } from './types';
import {
ClientRequestMaker,
TCustomizableRequestArgs,
Expand Down Expand Up @@ -46,25 +46,26 @@ export type TStreamClientRequestArgsWithoutAutoConnect = TStreamClientRequestArg
export default abstract class TwitterApiBase extends ClientRequestMaker {
protected _prefix: string | undefined;
protected _currentUser: UserV1 | null = null;
protected _currentUserV2: UserV2Result | null = null;

/**
* Create a new TwitterApi object without authentification.
* Create a new TwitterApi object without authentication.
*/
constructor();
/**
* Create a new TwitterApi object with OAuth 2.0 Bearer authentification.
* Create a new TwitterApi object with OAuth 2.0 Bearer authentication.
*/
constructor(bearerToken: string);
/**
* Create a new TwitterApi object with three-legged OAuth 1.0a authentification.
* Create a new TwitterApi object with three-legged OAuth 1.0a authentication.
*/
constructor(tokens: TwitterApiTokens);
/**
* Create a new TwitterApi object with only client ID needed for OAuth2 user-flow.
*/
constructor(oauth2Init: TwitterApiOAuth2Init);
/**
* Create a new TwitterApi object with Basic HTTP authentification.
* Create a new TwitterApi object with Basic HTTP authentication.
*/
constructor(credentials: TwitterApiBasicAuth);
/**
Expand All @@ -88,6 +89,7 @@ export default abstract class TwitterApiBase extends ClientRequestMaker {
this._bearerToken = token._bearerToken;
this._basicToken = token._basicToken;
this._clientId = token._clientId;
this._clientSecret = token._clientSecret;
this._rateLimits = token._rateLimits;
}
else if (typeof token === 'object' && 'appKey' in token) {
Expand All @@ -107,6 +109,7 @@ export default abstract class TwitterApiBase extends ClientRequestMaker {
}
else if (typeof token === 'object' && 'clientId' in token) {
this._clientId = token.clientId;
this._clientSecret = token.clientSecret;
}
}

Expand Down Expand Up @@ -208,6 +211,25 @@ export default abstract class TwitterApiBase extends ClientRequestMaker {
return currentUser;
}

/**
* Get cached current user from v2 API.
* This can only be the slimest available `UserV2` object, with only `id`, `name` and `username` properties defined.
*
* To get a customized `UserV2Result`, use `.v2.me()`
*
* OAuth2 scopes: `tweet.read` & `users.read`
*/
protected async getCurrentUserV2Object(forceFetch = false) {
if (!forceFetch && this._currentUserV2) {
return this._currentUserV2;
}

const currentUserV2 = await this.get<UserV2Result>('users/me', undefined, { prefix: 'https://api.twitter.com/2/' });
this._currentUserV2 = currentUserV2;

return currentUserV2;
}

/* Direct HTTP methods */

async get<T = any>(url: string, query?: TRequestQuery, args?: TGetClientRequestArgsDataResponse) : Promise<T>;
Expand Down
Loading

0 comments on commit c8dacc7

Please sign in to comment.