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`); +}); 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/OAuth2User.ts b/src/OAuth2User.ts index 0724a92..406acf7 100644 --- a/src/OAuth2User.ts +++ b/src/OAuth2User.ts @@ -26,13 +26,40 @@ export type OAuth2Scopes = | "bookmark.read" | "bookmark.write"; -export type OAuth2UserOptions = { +export interface OAuth2UserOptions { + /** Can be found in the developer portal under the header "Client ID". */ client_id: string; - client_secret: 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"; + }; + +export interface RevokeAccessTokenParams { + token_type_hint: string; + token: string; + client_id: string; +} function sha256(buffer: string) { return crypto.createHash("sha256").update(buffer).digest(); @@ -46,43 +73,62 @@ function base64URLEncode(str: Buffer) { .replace(/=/g, ""); } +interface RevokeAccessTokenResponse { + revoked: boolean; +} + +/** + * Twitter OAuth2 Authentication Client + */ 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); } - updateToken(data: Record) { + /** + * Update token information + */ + updateToken(data: Record): void { this.refresh_token = data.refresh_token; this.#access_token = data.access_token; this.token_type = data.token_type; @@ -90,7 +136,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 +148,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, }); 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 ); 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"]