-
Notifications
You must be signed in to change notification settings - Fork 464
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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]
- Loading branch information
Showing
5 changed files
with
241 additions
and
57 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |