Skip to content

Commit

Permalink
feat(iam): add twitter oauth procedure. implement twitter provider logic
Browse files Browse the repository at this point in the history
- 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]
  • Loading branch information
shavinac authored and gdixon committed May 16, 2022
1 parent 7683d3e commit 057dca5
Show file tree
Hide file tree
Showing 5 changed files with 241 additions and 57 deletions.
100 changes: 100 additions & 0 deletions iam/__tests__/twitter.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
37 changes: 5 additions & 32 deletions iam/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ dotenv.config();

// ---- Server
import express, { Request } from "express";
import { router as procedureRouter } from "./procedures";

// ---- Production plugins
import cors from "cors";
Expand Down Expand Up @@ -31,13 +32,15 @@ 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...
const providers = new Providers([
// Example provider which verifies the payload when `payload.proofs.valid === "true"`
new SimpleProvider(),
new GoogleProvider(),
new TwitterProvider(),
new EnsProvider(),
]);

Expand Down Expand Up @@ -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);
27 changes: 27 additions & 0 deletions iam/src/procedures/index.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
83 changes: 58 additions & 25 deletions iam/src/procedures/twitterOauth.ts
Original file line number Diff line number Diff line change
@@ -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 <SessionKey, auth.OAuth2User>
export const clients: Record<string, auth.OAuth2User> = {};

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<TwitterFindMyUserResponse> => {
// 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 };
};
51 changes: 51 additions & 0 deletions iam/src/providers/twitter.ts
Original file line number Diff line number Diff line change
@@ -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<VerifiedPayload> {
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<TwitterFindMyUserResponse> {
const client = getClient(sessionKey);

const myUser = await requestFindMyUser(client, code);

deleteClient(sessionKey);

return myUser;
}

0 comments on commit 057dca5

Please sign in to comment.