From 057dca5940a60cc6b4f7e69960b6e32b1d6b5b42 Mon Sep 17 00:00:00 2001 From: Shavina Chau Date: Thu, 12 May 2022 15:14:41 -0500 Subject: [PATCH] feat(iam): add twitter oauth procedure. implement twitter provider logic - add procedures/twitterOauth.ts to handle server-side OAuth 2.0 flow - implement providers/twitter to verify a user's username given an oauth access code and session key we must implement OAuth flow server-side because Twitter API currently does not allow the bearer token request from a browser (CORS issue). this means the twitter stamp is a multi-step process: 1. app requests the authorization url from iam, prompts user to click through 2. when user clicks through to twitter, and approves the oauth request, twitter redirects user back to app 3. app collects access code and state (session key) from query parameters on the redirect 4. app passes in access code and session key with the actual verify request to iam. iam exchanges access code for an auth bearer token to verify user's twitter info, and verify flow proceeds as normal [#28] --- iam/__tests__/twitter.test.ts | 100 +++++++++++++++++++++++++++++ iam/src/index.ts | 37 ++--------- iam/src/procedures/index.ts | 27 ++++++++ iam/src/procedures/twitterOauth.ts | 83 ++++++++++++++++-------- iam/src/providers/twitter.ts | 51 +++++++++++++++ 5 files changed, 241 insertions(+), 57 deletions(-) create mode 100644 iam/__tests__/twitter.test.ts create mode 100644 iam/src/procedures/index.ts create mode 100644 iam/src/providers/twitter.ts diff --git a/iam/__tests__/twitter.test.ts b/iam/__tests__/twitter.test.ts new file mode 100644 index 0000000000..d5119a1f08 --- /dev/null +++ b/iam/__tests__/twitter.test.ts @@ -0,0 +1,100 @@ +// ---- Test subject +import { TwitterProvider } from "../src/providers/twitter"; + +import { RequestPayload } from "@dpopp/types"; +import { auth } from "twitter-api-sdk"; +import { deleteClient, getClient, requestFindMyUser, TwitterFindMyUserResponse } from "../src/procedures/twitterOauth"; + +jest.mock("../src/procedures/twitterOauth", () => ({ + getClient: jest.fn(), + deleteClient: jest.fn(), + requestFindMyUser: jest.fn(), +})); + +const MOCK_TWITTER_OAUTH_CLIENT = {} as auth.OAuth2User; + +const MOCK_TWITTER_USER: TwitterFindMyUserResponse = { + id: "123", + name: "Userguy McTesterson", + username: "DpoppDev", +}; + +const sessionKey = "twitter-myOAuthSession"; +const code = "ABC123_ACCESSCODE"; + +beforeEach(() => { + jest.clearAllMocks(); + (getClient as jest.Mock).mockReturnValue(MOCK_TWITTER_OAUTH_CLIENT); +}); + +describe("Attempt verification", function () { + it("handles valid verification attempt", async () => { + (requestFindMyUser as jest.Mock).mockResolvedValue(MOCK_TWITTER_USER); + + const twitter = new TwitterProvider(); + const verifiedPayload = await twitter.verify({ + proofs: { + sessionKey, + code, + }, + } as unknown as RequestPayload); + + expect(getClient).toBeCalledWith(sessionKey); + expect(requestFindMyUser).toBeCalledWith(MOCK_TWITTER_OAUTH_CLIENT, code); + expect(deleteClient).toBeCalledWith(sessionKey); + expect(verifiedPayload).toEqual({ + valid: true, + record: { + username: MOCK_TWITTER_USER.username, + }, + }); + }); + + it("should return invalid payload when unable to retrieve twitter oauth client", async () => { + (getClient as jest.Mock).mockReturnValue(undefined); + (requestFindMyUser as jest.Mock).mockImplementationOnce(async (client) => { + return client ? MOCK_TWITTER_USER : {}; + }); + + const twitter = new TwitterProvider(); + + const verifiedPayload = await twitter.verify({ + proofs: { + sessionKey, + code, + }, + } as unknown as RequestPayload); + + expect(verifiedPayload).toMatchObject({ valid: false }); + }); + + it("should return invalid payload when there is no username in requestFindMyUser response", async () => { + (requestFindMyUser as jest.Mock).mockResolvedValue({ username: undefined }); + + const twitter = new TwitterProvider(); + + const verifiedPayload = await twitter.verify({ + proofs: { + sessionKey, + code, + }, + } as unknown as RequestPayload); + + expect(verifiedPayload).toMatchObject({ valid: false }); + }); + + it("should return invalid payload when requestFindMyUser throws", async () => { + (requestFindMyUser as jest.Mock).mockRejectedValue("unauthorized"); + + const twitter = new TwitterProvider(); + + const verifiedPayload = await twitter.verify({ + proofs: { + sessionKey, + code, + }, + } as unknown as RequestPayload); + + expect(verifiedPayload).toMatchObject({ valid: false }); + }); +}); diff --git a/iam/src/index.ts b/iam/src/index.ts index 64a60600b4..78f5a76941 100644 --- a/iam/src/index.ts +++ b/iam/src/index.ts @@ -4,6 +4,7 @@ dotenv.config(); // ---- Server import express, { Request } from "express"; +import { router as procedureRouter } from "./procedures"; // ---- Production plugins import cors from "cors"; @@ -31,6 +32,7 @@ import { Providers } from "./utils/providers"; // ---- Identity Providers import { SimpleProvider } from "./providers/simple"; import { GoogleProvider } from "./providers/google"; +import { TwitterProvider } from "./providers/twitter"; import { EnsProvider } from "./providers/ens"; // Initiate providers - new Providers should be registered in this array... @@ -38,6 +40,7 @@ const providers = new Providers([ // Example provider which verifies the payload when `payload.proofs.valid === "true"` new SimpleProvider(), new GoogleProvider(), + new TwitterProvider(), new EnsProvider(), ]); @@ -201,35 +204,5 @@ app.post("/api/v0.0.0/verify", (req: Request, res: Response): void => { }); }); -import { initClient, generateAuthURL, getClient } from "./procedures/twitterOauth"; - -/* - Generate auth URL & request access token within IAM server. - Note: MUST use same instance/object of OAuth2User during generateAuthUrl AND requestAccessToken - for OAuth code_challenge/code_verifier to work correctly. - This is because there are code_challenge / code_verifier values that are - set in OAuth2User when generateAuthUrl action is done -- these values are checked - during the requestAccessToken action. - */ -app.post("/twitter/generateAuthUrl", (req: Request, res: Response): void => { - const { callback, state } = req.body; - - initClient(callback); - - const data = { - authUrl: generateAuthURL(state), - }; - - // client redirects user to auth URL - // user complete oauth on twitter - - res.status(200).send(data); -}); - -app.post("/twitter/requestAccessToken", (req: Request, res: Response): void => { - const { code } = req.query; - const twitterClient = getClient(); - - // can do this when client calls IAM server `/verify` to verify username & issue credential - twitterClient.requestAccessToken(code as string); -}); +// procedure endpoints +app.use("/procedure", procedureRouter); diff --git a/iam/src/procedures/index.ts b/iam/src/procedures/index.ts new file mode 100644 index 0000000000..e6a4597177 --- /dev/null +++ b/iam/src/procedures/index.ts @@ -0,0 +1,27 @@ +// ---- Server +import { Request, Response, Router } from "express"; + +import { generateAuthURL, getSessionKey, initClient } from "./twitterOauth"; + +export const router = Router(); + +export type GenerateTwitterAuthUrlRequestBody = { + callback: string; +}; + +router.post("/twitter/generateAuthUrl", (req: Request, res: Response): void => { + const { callback } = req.body as GenerateTwitterAuthUrlRequestBody; + if (callback) { + const state = getSessionKey(); + const client = initClient(callback, state); + + const data = { + state, + authUrl: generateAuthURL(client, state), + }; + + res.status(200).send(data); + } else { + res.status(400); + } +}); diff --git a/iam/src/procedures/twitterOauth.ts b/iam/src/procedures/twitterOauth.ts index ba864eefd2..8ef88ac574 100644 --- a/iam/src/procedures/twitterOauth.ts +++ b/iam/src/procedures/twitterOauth.ts @@ -1,41 +1,74 @@ +import crypto from "crypto"; import { auth, Client } from "twitter-api-sdk"; -// must keep the same OAuth2User instance until the OAuth flow is done -// TODO - how to handle multiple requesters (who need multiple different OAuth2User)? -// - create a map of { callback url -> OAuth2User } ? -let twitterClient: auth.OAuth2User; +/* + Procedure to generate auth URL & request access token for Twitter OAuth -export const initClient = (callback: string) => { - return new auth.OAuth2User({ + We MUST use the same instance/object of OAuth2User during generateAuthUrl AND requestAccessToken (bearer token) processes. + This is because there are private values (code_challenge, code_verifier) that are + set in the OAuth2User instance when generateAuthUrl action is performed -- these private values are used + during the requestAccessToken process. +*/ + +const TIMEOUT_IN_MS = 60000; // 60000ms = 60s + +// Map +export const clients: Record = {}; + +export const getSessionKey = (): string => { + return `twitter-${crypto.randomBytes(32).toString("hex")}`; +}; +/** + * Initializes a Twitter OAuth2 Authentication Client + * @param callback redirect URI to use. Don’t use localhost as a callback URL - instead, please use a custom host locally or http(s)://127.0.0.1 + * @param sessionKey associates a specific auth.OAuth2User instance to a session + * @returns instance of auth.OAuth2User + */ +export const initClient = (callback: string, sessionKey: string): auth.OAuth2User => { + clients[sessionKey] = new auth.OAuth2User({ client_id: process.env.TWITTER_CLIENT_ID, client_secret: process.env.TWITTER_CLIENT_SECRET, - callback: callback, // TODO - use http(s)://127.0.0.1, NOT localhost + callback: callback, scopes: ["tweet.read", "users.read"], }); + + // stope the clients from causing a memory leak + setTimeout(() => { + deleteClient(sessionKey); + }, TIMEOUT_IN_MS); + + return clients[sessionKey]; }; -export const getClient = () => { - return twitterClient; +export const deleteClient = (state: string): void => { + delete clients[state]; }; -export const generateAuthURL = (state: string): string => - getClient().generateAuthURL({ +export const getClient = (state: string): auth.OAuth2User | undefined => { + return clients[state]; +}; + +// This method has side-effects which alter unaccessible state on the +// OAuth2User instance. The correct state values need to be present when we request the access token +export const generateAuthURL = (client: auth.OAuth2User, state: string): string => { + return client.generateAuthURL({ state, code_challenge_method: "s256", }); +}; + +export type TwitterFindMyUserResponse = { + id?: string; + name?: string; + username?: string; +}; + +export const requestFindMyUser = async (client: auth.OAuth2User, code: string): Promise => { + // retrieve user's auth bearer token to authenticate client + await client.requestAccessToken(code); -// TODO - use something like this to retrieve twitter handle for /verify flow -const client = new Client(twitterClient); -export const getMyTwitterUsername = async () => { - const myUser = await client.users.findMyUser(); - return myUser.data.username; - /* - { - "data": { - "id": "1234567890", - "name": "Your Twitter Friendly Name", - "username": "Your Twitter Handle" - } - } - */ + // return information about the (authenticated) requesting user + const twitterClient = new Client(client); + const myUser = await twitterClient.users.findMyUser(); + return { ...myUser.data }; }; diff --git a/iam/src/providers/twitter.ts b/iam/src/providers/twitter.ts new file mode 100644 index 0000000000..6fb7e9c86a --- /dev/null +++ b/iam/src/providers/twitter.ts @@ -0,0 +1,51 @@ +// ----- Types +import type { RequestPayload, VerifiedPayload } from "@dpopp/types"; + +// ----- Twitters OAuth2 library +import { deleteClient, getClient, requestFindMyUser, TwitterFindMyUserResponse } from "../procedures/twitterOauth"; +import type { Provider, ProviderOptions } from "../types"; + +// Export a Twitter Provider to carry out OAuth and return a record object +export class TwitterProvider implements Provider { + // Give the provider a type so that we can select it with a payload + type = "Twitter"; + // Options can be set here and/or via the constructor + _options = {}; + + // construct the provider instance with supplied options + constructor(options: ProviderOptions = {}) { + this._options = { ...this._options, ...options }; + } + + // verify that the proof object contains valid === "true" + async verify(payload: RequestPayload): Promise { + let valid = false, + verifiedPayload: TwitterFindMyUserResponse = {}; + + try { + verifiedPayload = await verifyTwitter(payload.proofs.sessionKey, payload.proofs.code); + } catch (e) { + return { valid: false }; + } finally { + valid = verifiedPayload && verifiedPayload.username ? true : false; + } + + return { + valid: valid, + record: { + username: verifiedPayload.username, + }, + }; + } +} + +// Perform verification on twitter access token +async function verifyTwitter(sessionKey: string, code: string): Promise { + const client = getClient(sessionKey); + + const myUser = await requestFindMyUser(client, code); + + deleteClient(sessionKey); + + return myUser; +}