From 875fd79fe844b6047ad425431788fd795ed473bc Mon Sep 17 00:00:00 2001 From: refarer <14077091+refarer@users.noreply.github.com> Date: Thu, 21 Apr 2022 15:17:15 +1200 Subject: [PATCH 1/6] fix 201 success responses --- src/types.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/types.ts b/src/types.ts index ee0606f..af98f29 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,8 @@ // Copyright 2021 Twitter, Inc. // SPDX-License-Identifier: Apache-2.0 -type SuccessStatus = 200; -type ResponseType = "application/json"; +export type SuccessStatus = 200 | 201; +export type ResponseType = "application/json"; export interface AuthHeader { Authorization: string; @@ -33,18 +33,22 @@ export type UnionToIntersection = ( ? I : never; +export type GetSuccess = { + [K in SuccessStatus & keyof T]: GetContent; +}[SuccessStatus & keyof T]; + export type TwitterResponse = UnionToIntersection>; -export type ExtractTwitterResponse = "responses" extends keyof T - ? SuccessStatus extends keyof T["responses"] - ? "content" extends keyof T["responses"][SuccessStatus] - ? ResponseType extends keyof T["responses"][SuccessStatus]["content"] - ? T["responses"][SuccessStatus]["content"][ResponseType] - : never - : never +export type GetContent = "content" extends keyof T + ? ResponseType extends keyof T["content"] + ? T["content"][ResponseType] : never : never; +export type ExtractTwitterResponse = "responses" extends keyof T + ? GetSuccess + : never; + export type TwitterParams = "parameters" extends keyof T ? "query" extends keyof T["parameters"] ? T["parameters"]["query"] From d6b704372e97da83948e9bd0e5434aedc594d112 Mon Sep 17 00:00:00 2001 From: refarer <14077091+refarer@users.noreply.github.com> Date: Thu, 21 Apr 2022 15:19:42 +1200 Subject: [PATCH 2/6] 60 second timeout --- package.json | 2 +- src/request.ts | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 1a7ad34..6db50e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "twitter-api-sdk", - "version": "1.0.3", + "version": "1.0.4", "description": "A TypeScript SDK for the Twitter API", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/request.ts b/src/request.ts index 9a33401..095303f 100644 --- a/src/request.ts +++ b/src/request.ts @@ -46,10 +46,11 @@ export async function request({ request_body, method, max_retries, - base_url: baseUrl = "https://api.twitter.com", + base_url = "https://api.twitter.com", + headers, ...options }: RequestOptions): Promise { - const url = new URL(baseUrl + endpoint); + const url = new URL(base_url + endpoint); url.search = buildQueryString(query); const isPost = method === "POST" && !!request_body; const authHeader = auth @@ -58,17 +59,20 @@ export async function request({ const response = await fetchWithRetries( url.toString(), { - ...options, headers: { - ...options.headers, - "User-Agent": `twitter-api-typescript-sdk/1.0.3`, + "User-Agent": `twitter-api-typescript-sdk/1.0.4`, ...(isPost ? { "Content-Type": "application/json; charset=utf-8" } : undefined), ...authHeader, + ...headers, }, method, body: isPost ? JSON.stringify(request_body) : undefined, + // Timeout if you don't see any data for 60 seconds + // https://developer.twitter.com/en/docs/tutorials/consuming-streaming-data + timeout: 60000, + ...options, }, max_retries ); From 78332bef028787678079a7e27ae4f6fc0a8917a7 Mon Sep 17 00:00:00 2001 From: refarer <14077091+refarer@users.noreply.github.com> Date: Thu, 21 Apr 2022 15:20:31 +1200 Subject: [PATCH 3/6] OAuth2 public client support --- src/OAuth2User.ts | 179 ++++++++++++++++++++++++++++++---------------- 1 file changed, 116 insertions(+), 63 deletions(-) diff --git a/src/OAuth2User.ts b/src/OAuth2User.ts index 0724a92..b5a2bd9 100644 --- a/src/OAuth2User.ts +++ b/src/OAuth2User.ts @@ -26,13 +26,30 @@ export type OAuth2Scopes = | "bookmark.read" | "bookmark.write"; -export type OAuth2UserOptions = { +export interface OAuth2UserOptions { client_id: string; - client_secret: string; + client_secret?: string; callback: string; scopes: OAuth2Scopes[]; request_options?: Partial; -}; +} + +export type GenerateAuthUrlOptions = + | { + state: string; + code_challenge_method: "s256"; + } + | { + state: string; + code_challenge: string; + 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(); @@ -46,42 +63,64 @@ function base64URLEncode(str: Buffer) { .replace(/=/g, ""); } +interface RevokeAccessTokenResponse { + revoked: boolean; +} + +/** + * Twitter OAuth2 Authentication Client + * + * TypeScript Authentication Client for use with the Twitter API OAuth2 + * + */ export class OAuth2User implements AuthClient { #access_token?: string; token_type?: string; expires_at?: Date; scope?: string; refresh_token?: string; - #configuration: OAuth2UserOptions; + #options: OAuth2UserOptions; #code_verifier?: string; #code_challenge?: string; - constructor(configuration: OAuth2UserOptions) { - this.#configuration = configuration; + constructor(options: OAuth2UserOptions) { + this.#options = options; } - async refreshAccessToken() { + /** + * Refresh the access token + */ + async refreshAccessToken(): Promise { const refresh_token = this.refresh_token; - const credentials = this.#configuration; + 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({ - ...this.#configuration.request_options, + ...request_options, endpoint: `/2/oauth2/token`, params: { + client_id, grant_type: "refresh_token", refresh_token, }, method: "POST", headers: { - ...this.#configuration.request_options?.headers, + ...request_options?.headers, "Content-type": "application/x-www-form-urlencoded", - Authorization: basicAuthHeader( - credentials.client_id, - credentials.client_secret - ), + ...(!!client_secret && { + Authorization: basicAuthHeader(client_id, client_secret), + }), }, }); this.updateToken(data); } + /** + * Update token information + */ updateToken(data: Record) { this.refresh_token = data.refresh_token; this.#access_token = data.access_token; @@ -90,7 +129,10 @@ export class OAuth2User implements AuthClient { this.scope = data.scope; } - isAccessTokenExpired() { + /** + * Check if an access token is expired + */ + isAccessTokenExpired(): boolean { const refresh_token = this.refresh_token; const expires_at = this.expires_at; return ( @@ -99,92 +141,103 @@ export class OAuth2User implements AuthClient { ); } + /** + * Request an access token + */ async requestAccessToken(code?: string): Promise { - const credentials = this.#configuration; - const code_verifier = this.#code_verifier || this.#code_challenge; + 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: code_verifier, - client_id: credentials.client_id, - redirect_uri: credentials.callback, + code_verifier, + client_id, + redirect_uri: callback, }; const data = await rest({ - ...this.#configuration.request_options, + ...request_options, endpoint: `/2/oauth2/token`, - params: params, + params, method: "POST", headers: { - ...this.#configuration.request_options?.headers, + ...request_options?.headers, "Content-type": "application/x-www-form-urlencoded", - Authorization: basicAuthHeader( - credentials.client_id, - credentials.client_secret - ), + ...(!!client_secret && { + Authorization: basicAuthHeader(client_id, client_secret), + }), }, }); this.updateToken(data); } - async revokeAccessToken(): Promise { - const credentials = this.#configuration; + /** + * Revoke an access token + */ + async revokeAccessToken(): Promise { + const { client_id, client_secret, request_options } = this.#options; const access_token = this.#access_token; const refresh_token = this.refresh_token; - const configuration = this.#configuration; - if (!access_token || !refresh_token) - throw new Error("No access_token or refresh_token found"); - const useAccessToken = !!this.#access_token; - const params = { - token_type_hint: useAccessToken ? "access_token" : "refresh_token", - token: useAccessToken ? access_token : refresh_token, - client_id: configuration.client_id, - }; + 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({ - ...this.#configuration.request_options, + ...request_options, endpoint: `/2/oauth2/revoke`, - params: params, + params, method: "POST", headers: { - ...this.#configuration.request_options?.headers, + ...request_options?.headers, "Content-Type": "application/x-www-form-urlencoded", - Authorization: basicAuthHeader( - credentials.client_id, - credentials.client_secret - ), + ...(!!client_secret && { + Authorization: basicAuthHeader(client_id, client_secret), + }), }, }); } - generateAuthURL( - options: - | { - state: string; - code_challenge: string; - code_challenge_method?: "plain"; - } - | { - state: string; - code_challenge_method: "s256"; - } - ): string { - const credentials = this.#configuration; - if (!("callback" in credentials)) - throw new Error("You need to provide a callback and scopes"); + 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: credentials.client_id, - scope: credentials.scopes.join(" "), + client_id, + scope: scopes.join(" "), response_type: "code", - redirect_uri: credentials.callback, + redirect_uri: callback, code_challenge_method: options.code_challenge_method || "plain", code_challenge, }); From add26d9bce61cbccc9c9f70d45f767e69cc92a98 Mon Sep 17 00:00:00 2001 From: refarer <14077091+refarer@users.noreply.github.com> Date: Thu, 21 Apr 2022 15:20:38 +1200 Subject: [PATCH 4/6] update examples --- examples/oauth2-callback.ts | 18 +++--- examples/oauth2-callback_pkce_plain.ts | 6 +- examples/oauth2-callback_pkce_s256.ts | 6 +- examples/oauth2-public-callback_pkce_s256.ts | 66 ++++++++++++++++++++ 4 files changed, 77 insertions(+), 19 deletions(-) create mode 100644 examples/oauth2-public-callback_pkce_s256.ts diff --git a/examples/oauth2-callback.ts b/examples/oauth2-callback.ts index fa2c4a2..10c95b5 100644 --- a/examples/oauth2-callback.ts +++ b/examples/oauth2-callback.ts @@ -13,7 +13,7 @@ const authClient = new auth.OAuth2User({ client_id: process.env.CLIENT_ID as string, client_secret: process.env.CLIENT_SECRET as string, callback: "http://127.0.0.1:3000/callback", - scopes: ["tweet.read", "users.read", "offline.access"], + scopes: ["tweet.read", "users.read"], }); const client = new Client(authClient); @@ -23,9 +23,7 @@ const STATE = "my-state"; app.get("/callback", async function (req, res) { try { const { code, state } = req.query; - if (state !== STATE) { - return res.status(500).send("State isn't matching"); - } + if (state !== STATE) return res.status(500).send("State isn't matching"); await authClient.requestAccessToken(code as string); res.redirect("/tweets"); } catch (error) { @@ -36,7 +34,7 @@ app.get("/callback", async function (req, res) { app.get("/login", async function (req, res) { const authUrl = authClient.generateAuthURL({ state: STATE, - code_challenge: "challenge", + code_challenge_method: "s256", }); res.redirect(authUrl); }); @@ -47,14 +45,16 @@ app.get("/tweets", async function (req, res) { }); app.get("/revoke", async function (req, res) { - const refresh_token = authClient.refresh_token; - if (refresh_token) { + try { const response = await authClient.revokeAccessToken(); - return res.send(response); + res.send(response); + } catch (error) { + console.log(error); } - res.send("No access token to revoke"); }); + + app.listen(3000, () => { console.log(`Go here to login: http://127.0.0.1:3000/login`); }); diff --git a/examples/oauth2-callback_pkce_plain.ts b/examples/oauth2-callback_pkce_plain.ts index f204a1f..647e7cf 100644 --- a/examples/oauth2-callback_pkce_plain.ts +++ b/examples/oauth2-callback_pkce_plain.ts @@ -23,9 +23,7 @@ const STATE = "my-state"; app.get("/callback", async function (req, res) { try { const { code, state } = req.query; - if (state !== STATE) { - return res.status(500).send("State isn't matching"); - } + if (state !== STATE) return res.status(500).send("State isn't matching"); await authClient.requestAccessToken(code as string); res.redirect("/tweets"); } catch (error) { @@ -53,8 +51,6 @@ app.get("/tweets", async function (req, res) { app.get("/revoke", async function (req, res) { try { - const refresh_token = authClient.refresh_token; - if (!refresh_token) return res.send("No refresh_token found"); const response = await authClient.revokeAccessToken(); res.send(response); } catch (error) { diff --git a/examples/oauth2-callback_pkce_s256.ts b/examples/oauth2-callback_pkce_s256.ts index 54dd083..4f86cad 100644 --- a/examples/oauth2-callback_pkce_s256.ts +++ b/examples/oauth2-callback_pkce_s256.ts @@ -23,9 +23,7 @@ const STATE = "my-state"; app.get("/callback", async function (req, res) { try { const { code, state } = req.query; - if (state !== STATE) { - return res.status(500).send("State isn't matching"); - } + if (state !== STATE) return res.status(500).send("State isn't matching"); await authClient.requestAccessToken(code as string); res.redirect("/tweets"); } catch (error) { @@ -52,8 +50,6 @@ app.get("/tweets", async function (req, res) { app.get("/revoke", async function (req, res) { try { - const refresh_token = authClient.refresh_token; - if (!refresh_token) return res.send("No refresh_token found"); const response = await authClient.revokeAccessToken(); res.send(response); } catch (error) { diff --git a/examples/oauth2-public-callback_pkce_s256.ts b/examples/oauth2-public-callback_pkce_s256.ts new file mode 100644 index 0000000..39c08d1 --- /dev/null +++ b/examples/oauth2-public-callback_pkce_s256.ts @@ -0,0 +1,66 @@ +// Copyright 2021 Twitter, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Client, auth } from "twitter-api-sdk"; +import express from "express"; +import dotenv from "dotenv"; + +dotenv.config(); + +const app = express(); + +const authClient = new auth.OAuth2User({ + client_id: process.env.CLIENT_ID as string, + callback: "http://127.0.0.1:3000/callback", + scopes: ["tweet.read", "users.read", "offline.access"], +}); + +const client = new Client(authClient); + +const STATE = "my-state"; + +app.get("/callback", async function (req, res) { + try { + const { code, state } = req.query; + if (state !== STATE) return res.status(500).send("State isn't matching"); + await authClient.requestAccessToken(code as string); + res.redirect("/tweets"); + } catch (error) { + console.log(error); + } +}); + +app.get("/login", async function (req, res) { + const authUrl = authClient.generateAuthURL({ + state: STATE, + code_challenge_method: "s256", + }); + res.redirect(authUrl); +}); + +app.get("/revoke", async function (req, res) { + try { + const response = await authClient.revokeAccessToken(); + res.send(response); + } catch (error) { + console.log(error); + } +}); + +app.get("/tweets", async function (req, res) { + const tweets = await client.tweets.findTweetById("20"); + res.send(tweets.data); +}); + +app.get("/refresh", async function (req, res) { + try { + await authClient.refreshAccessToken(); + res.send("Refreshed Access Token"); + } catch (error) { + console.log(error); + } +}); + +app.listen(3000, () => { + console.log(`Go here to login: http://127.0.0.1:3000/login`); +}); From 729cf8422632a20a116e3687b86919be7e431bd2 Mon Sep 17 00:00:00 2001 From: refarer <14077091+refarer@users.noreply.github.com> Date: Fri, 22 Apr 2022 13:39:14 +1200 Subject: [PATCH 5/6] inline comments for oauth2 --- src/OAuth2User.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/OAuth2User.ts b/src/OAuth2User.ts index b5a2bd9..7186dc0 100644 --- a/src/OAuth2User.ts +++ b/src/OAuth2User.ts @@ -27,21 +27,31 @@ export type OAuth2Scopes = | "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; } 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"; }; @@ -69,9 +79,6 @@ interface RevokeAccessTokenResponse { /** * Twitter OAuth2 Authentication Client - * - * TypeScript Authentication Client for use with the Twitter API OAuth2 - * */ export class OAuth2User implements AuthClient { #access_token?: string; From ce8b18b04185bd7f6744ae743f0e5b2bef8df203 Mon Sep 17 00:00:00 2001 From: refarer <14077091+refarer@users.noreply.github.com> Date: Fri, 22 Apr 2022 13:40:25 +1200 Subject: [PATCH 6/6] return type for updateToken --- src/OAuth2User.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OAuth2User.ts b/src/OAuth2User.ts index 7186dc0..406acf7 100644 --- a/src/OAuth2User.ts +++ b/src/OAuth2User.ts @@ -128,7 +128,7 @@ export class OAuth2User implements AuthClient { /** * Update token information */ - updateToken(data: Record) { + updateToken(data: Record): void { this.refresh_token = data.refresh_token; this.#access_token = data.access_token; this.token_type = data.token_type;