forked from xdevplatform/twitter-api-typescript-sdk
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathOAuth2User.ts
278 lines (260 loc) · 8.44 KB
/
OAuth2User.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
// Copyright 2021 Twitter, Inc.
// SPDX-License-Identifier: Apache-2.0
import crypto from "crypto";
import { buildQueryString, basicAuthHeader } from "./utils";
import { AuthClient, AuthHeader } from "./types";
import { RequestOptions, rest } from "./request";
export type OAuth2Scopes =
| "tweet.read"
| "tweet.write"
| "tweet.moderate.write"
| "users.read"
| "follows.read"
| "follows.write"
| "offline.access"
| "space.read"
| "mute.read"
| "mute.write"
| "like.read"
| "like.write"
| "list.read"
| "list.write"
| "block.read"
| "block.write"
| "bookmark.read"
| "bookmark.write";
export interface OAuth2UserOptions {
/** Can be found in the developer portal under the header "Client ID". */
client_id: string;
/** If you have selected an App type that is a confidential client you will be provided with a “Client Secret” under “Client ID” in your App’s keys and tokens section. */
client_secret?: string;
/**Your callback URL. This value must correspond to one of the Callback URLs defined in your App’s settings. For OAuth 2.0, you will need to have exact match validation for your callback URL. */
callback: string;
/** Scopes allow you to set granular access for your App so that your App only has the permissions that it needs. To learn more about what scopes map to what endpoints, view our {@link https://developer.twitter.com/en/docs/authentication/guides/v2-authentication-mapping authentication mapping guide}. */
scopes: OAuth2Scopes[];
/** Overwrite request options for all endpoints */
request_options?: Partial<RequestOptions>;
/** Set the auth token */
token?: Token;
}
export type GenerateAuthUrlOptions =
| {
/** A random string you provide to verify against CSRF attacks. The length of this string can be up to 500 characters. */
state: string;
/** Specifies the method you are using to make a request (S256 OR plain). */
code_challenge_method: "s256";
}
| {
/** A random string you provide to verify against CSRF attacks. The length of this string can be up to 500 characters. */
state: string;
/** A PKCE parameter, a random secret for each request you make. */
code_challenge: string;
/** Specifies the method you are using to make a request (S256 OR plain). */
code_challenge_method?: "plain";
};
export interface RevokeAccessTokenParams {
token_type_hint: string;
token: string;
client_id: string;
}
function sha256(buffer: string) {
return crypto.createHash("sha256").update(buffer).digest();
}
function base64URLEncode(str: Buffer) {
return str
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
interface RevokeAccessTokenResponse {
revoked: boolean;
}
interface GetTokenResponse {
/** Allows an application to obtain a new access token without prompting the user via the refresh token flow. */
refresh_token?: string;
/** Access tokens are the token that applications use to make API requests on behalf of a user. */
access_token?: string;
token_type?: string;
expires_in?: number;
/** Comma-separated list of scopes for the token */
scope?: string;
}
interface Token extends Omit<GetTokenResponse, "expires_in"> {
/** Date that the access_token will expire at. */
expires_at?: number;
}
function processTokenResponse(token: GetTokenResponse): Token {
const { expires_in, ...rest } = token;
return {
...rest,
...(!!expires_in && {
expires_at: Date.now() + expires_in * 1000,
}),
};
}
/**
* Twitter OAuth2 Authentication Client
*/
export class OAuth2User implements AuthClient {
token?: Token;
#options: OAuth2UserOptions;
#code_verifier?: string;
#code_challenge?: string;
constructor(options: OAuth2UserOptions) {
const { token, ...defaultOptions } = options;
this.#options = defaultOptions;
this.token = token;
}
/**
* Refresh the access token
*/
async refreshAccessToken(): Promise<{ token: Token }> {
const refresh_token = this.token?.refresh_token;
const { client_id, client_secret, request_options } = this.#options;
if (!client_id) {
throw new Error("client_id is required");
}
if (!refresh_token) {
throw new Error("refresh_token is required");
}
const data = await rest<GetTokenResponse>({
...request_options,
endpoint: `/2/oauth2/token`,
params: {
client_id,
grant_type: "refresh_token",
refresh_token,
},
method: "POST",
headers: {
...request_options?.headers,
"Content-type": "application/x-www-form-urlencoded",
...(!!client_secret && {
Authorization: basicAuthHeader(client_id, client_secret),
}),
},
});
const token = processTokenResponse(data);
this.token = token;
return { token };
}
/**
* Check if an access token is expired
*/
isAccessTokenExpired(): boolean {
const refresh_token = this.token?.refresh_token;
const expires_at = this.token?.expires_at;
if (!expires_at) return true;
return !!refresh_token && expires_at <= Date.now() + 1000;
}
/**
* Request an access token
*/
async requestAccessToken(code?: string): Promise<{ token: Token }> {
const { client_id, client_secret, callback, request_options } =
this.#options;
const code_verifier = this.#code_verifier;
if (!client_id) {
throw new Error("client_id is required");
}
if (!callback) {
throw new Error("callback is required");
}
const params = {
code,
grant_type: "authorization_code",
code_verifier,
client_id,
redirect_uri: callback,
};
const data = await rest<GetTokenResponse>({
...request_options,
endpoint: `/2/oauth2/token`,
params,
method: "POST",
headers: {
...request_options?.headers,
"Content-type": "application/x-www-form-urlencoded",
...(!!client_secret && {
Authorization: basicAuthHeader(client_id, client_secret),
}),
},
});
const token = processTokenResponse(data);
this.token = token;
return { token };
}
/**
* Revoke an access token
*/
async revokeAccessToken(): Promise<RevokeAccessTokenResponse> {
const { client_id, client_secret, request_options } = this.#options;
const access_token = this.token?.access_token;
const refresh_token = this.token?.refresh_token;
if (!client_id) {
throw new Error("client_id is required");
}
let params: RevokeAccessTokenParams;
if (!!access_token) {
params = {
token_type_hint: "access_token",
token: access_token,
client_id,
};
} else if (!!refresh_token) {
params = {
token_type_hint: "refresh_token",
token: refresh_token,
client_id,
};
} else {
throw new Error("access_token or refresh_token required");
}
return rest({
...request_options,
endpoint: `/2/oauth2/revoke`,
params,
method: "POST",
headers: {
...request_options?.headers,
"Content-Type": "application/x-www-form-urlencoded",
...(!!client_secret && {
Authorization: basicAuthHeader(client_id, client_secret),
}),
},
});
}
generateAuthURL(options: GenerateAuthUrlOptions): string {
const { client_id, callback, scopes } = this.#options;
if (!callback) throw new Error("callback required");
if (!scopes) throw new Error("scopes required");
if (options.code_challenge_method === "s256") {
const code_verifier = base64URLEncode(crypto.randomBytes(32));
this.#code_verifier = code_verifier;
this.#code_challenge = base64URLEncode(sha256(code_verifier));
} else {
this.#code_challenge = options.code_challenge;
this.#code_verifier = options.code_challenge;
}
const code_challenge = this.#code_challenge;
const url = new URL("https://twitter.com/i/oauth2/authorize");
url.search = buildQueryString({
...options,
client_id,
scope: scopes.join(" "),
response_type: "code",
redirect_uri: callback,
code_challenge_method: options.code_challenge_method || "plain",
code_challenge,
});
return url.toString();
}
async getAuthHeader(): Promise<AuthHeader> {
if (!this.token?.access_token) throw new Error("access_token is required");
if (this.isAccessTokenExpired()) await this.refreshAccessToken();
return {
Authorization: `Bearer ${this.token.access_token}`,
};
}
}