Skip to content

Commit

Permalink
Remove V1.1 verify_credentials call after successful token retrieval
Browse files Browse the repository at this point in the history
  • Loading branch information
na2hiro committed Jun 17, 2023
1 parent 80323f6 commit e2469f9
Show file tree
Hide file tree
Showing 3 changed files with 38 additions and 173 deletions.
88 changes: 28 additions & 60 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,31 +14,30 @@ import { v4 as uuid } from "uuid";
import hmacSHA1 from "crypto-js/hmac-sha1";
import Base64 from "crypto-js/enc-base64";
import { fixedEncodeURIComponent } from "./utils";
import type { TwitterProfile } from "./twitterInterface";

export type { TwitterProfile } from "./twitterInterface";

let debug = createDebug("TwitterStrategy");

const requestTokenURL = "https://api.twitter.com/oauth/request_token";
const authorizationURL = "https://api.twitter.com/oauth/authorize";
const authenticationURL = "https://api.twitter.com/oauth/authenticate";
const tokenURL = "https://api.twitter.com/oauth/access_token";
const verifyCredentialsURL =
"https://api.twitter.com/1.1/account/verify_credentials.json";

export interface TwitterStrategyOptions {
clientID: string;
clientSecret: string;
callbackURL: string;
includeEmail?: boolean;
alwaysReauthorize?: boolean;
}

export interface Profile {
userId: string;
screenName: string;
}

export interface TwitterStrategyVerifyParams {
accessToken: string;
accessTokenSecret: string;
profile: TwitterProfile;
profile: Profile;
context?: AppLoadContext;
}

Expand All @@ -60,7 +59,6 @@ export const TwitterStrategyDefaultName = "twitter";
* - `clientID` identifies client to service provider
* - `clientSecret` secret used to establish ownership of the client identifier
* - `callbackURL` URL to which the service provider will redirect the user after obtaining authorization
* - `includeEmail` Whether to return the user email (optional. default: false)
* - `alwaysReauthorize` If set to true, always as app permissions. This was v1 behavior.
* If false, just let them login if they've once accepted the permission. (optional. default: false)
*
Expand All @@ -70,7 +68,6 @@ export const TwitterStrategyDefaultName = "twitter";
* clientID: '123-456-789',
* clientSecret: 'shhh-its-a-secret',
* callbackURL: 'https://www.example.net/auth/example/callback',
* includeEmail: true
* },
* async ({ accessToken, accessTokenSecret, profile }) => {
* return await User.findOrCreate(profile.id, profile.email, ...);
Expand All @@ -86,7 +83,6 @@ export class TwitterStrategy<User> extends Strategy<
protected clientID: string;
protected clientSecret: string;
protected callbackURL: string;
protected includeEmail: boolean;
protected alwaysReauthorize: boolean;

constructor(
Expand All @@ -97,7 +93,6 @@ export class TwitterStrategy<User> extends Strategy<
this.clientID = options.clientID;
this.clientSecret = options.clientSecret;
this.callbackURL = options.callbackURL;
this.includeEmail = options.includeEmail || false;
this.alwaysReauthorize = options.alwaysReauthorize || false;
}

Expand All @@ -106,7 +101,7 @@ export class TwitterStrategy<User> extends Strategy<
sessionStorage: SessionStorage,
options: AuthenticateOptions
): Promise<User> {
debug("Request URL", request.url);
debug("Request URL", request.url.toString());
let url = new URL(request.url);
let session = await sessionStorage.getSession(
request.headers.get("Cookie")
Expand All @@ -121,11 +116,10 @@ export class TwitterStrategy<User> extends Strategy<
}

let callbackURL = this.getCallbackURL(url);
debug("Callback URL", callbackURL);
debug("Callback URL", callbackURL.toString());

// Before user navigates to login page: Redirect to login page
if (url.pathname !== callbackURL.pathname) {
debug("Requesting request token");
// Unlike OAuth2, we first hit the request token endpoint
const { requestToken, callbackConfirmed } = await this.fetchRequestToken(
callbackURL
Expand All @@ -152,6 +146,7 @@ export class TwitterStrategy<User> extends Strategy<

const denied = url.searchParams.get("denied");
if (denied) {
debug("Denied");
return await this.failure(
"Please authorize the app",
request,
Expand All @@ -177,16 +172,8 @@ export class TwitterStrategy<User> extends Strategy<
params.set("oauth_token", oauthToken);
params.set("oauth_verifier", oauthVerifier);

let { accessToken, accessTokenSecret } = await this.fetchAccessToken(
params
);

// Get the profile
let profile = await this.userProfile(
accessToken,
accessTokenSecret,
this.includeEmail
);
let { accessToken, accessTokenSecret, ...profile } =
await this.fetchAccessTokenAndProfile(params);

// Verify the user and return it, or redirect
try {
Expand Down Expand Up @@ -242,7 +229,9 @@ export class TwitterStrategy<User> extends Strategy<
);
const url = new URL(requestTokenURL);
url.search = new URLSearchParams(parameters).toString();
let response = await fetch(url.toString(), {
const urlString = url.toString();
debug("Fetching request token", urlString);
let response = await fetch(urlString, {
method: "GET",
});

Expand Down Expand Up @@ -319,12 +308,15 @@ export class TwitterStrategy<User> extends Strategy<
/**
* Step 3: Fetch access token to do anything
*/
private async fetchAccessToken(params: URLSearchParams): Promise<{
private async fetchAccessTokenAndProfile(params: URLSearchParams): Promise<{
accessToken: string;
accessTokenSecret: string;
userId: string;
screenName: string;
}> {
params.set("oauth_consumer_key", this.clientID);

debug("Fetch access token", tokenURL, params.toString());
let response = await fetch(tokenURL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
Expand All @@ -337,52 +329,28 @@ export class TwitterStrategy<User> extends Strategy<
throw new Response(body, { status: 401 });
}

return await this.getAccessToken(response.clone() as unknown as Response);
return await this.extractAccessTokenAndProfile(
response.clone() as unknown as Response
);
}

protected async getAccessToken(response: Response): Promise<{
protected async extractAccessTokenAndProfile(response: Response): Promise<{
accessToken: string;
accessTokenSecret: string;
userId: string;
screenName: string;
}> {
const text = await response.text();
const obj: { [key: string]: string } = {};
for (const pair of text.split("&")) {
const [key, value] = pair.split("=");
obj[key] = value;
}
const accessToken = obj.oauth_token as string;
const accessTokenSecret = obj.oauth_token_secret as string;
return {
accessToken,
accessTokenSecret,
accessToken: obj.oauth_token as string,
accessTokenSecret: obj.oauth_token_secret as string,
userId: obj.user_id as string,
screenName: obj.screen_name as string,
} as const;
}

/**
* Retrieve user profile from service provider.
*
* OAuth 2.0-based authentication strategies can override this function in
* order to load the user's profile from the service provider. This assists
* applications (and users of those applications) in the initial registration
* process by automatically submitting required information.
*/
protected async userProfile(
accessToken: string,
accessTokenSecret: string,
includeEmail: boolean
): Promise<TwitterProfile> {
const params = this.signRequest(
{
oauth_token: accessToken,
include_email: includeEmail ? "true" : "false",
},
"GET",
verifyCredentialsURL,
accessTokenSecret
);
const url = new URL(verifyCredentialsURL);
url.search = new URLSearchParams(params).toString();
const response = await fetch(url.toString());
return await response.json();
}
}
90 changes: 0 additions & 90 deletions src/twitterInterface.ts

This file was deleted.

33 changes: 10 additions & 23 deletions test/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { createCookieSessionStorage } from "@remix-run/node";
import fetchMock, { enableFetchMocks } from "jest-fetch-mock";

import { TwitterStrategy, TwitterStrategyOptions } from "../src";
import { TwitterProfile, TwitterStrategyVerifyParams } from "../build";
import { Profile, TwitterStrategy, TwitterStrategyVerifyParams } from "../src";

enableFetchMocks();

Expand Down Expand Up @@ -196,25 +195,14 @@ describe(TwitterStrategy, () => {
}
});

test("should call verify with the access token, access token secret, and user profile", async () => {
test("should return access token, access token secret, and a minimum user profile", async () => {
fetchMock.mockResponse(async (req) => {
const url = new URL(req.url);
url.search = "";
switch (url.toString()) {
case "https://api.twitter.com/oauth/access_token":
return {
body: "oauth_token=ACCESS_TOKEN&oauth_token_secret=ACCESS_TOKEN_SECRET",
init: {
status: 200,
},
};
case "https://api.twitter.com/1.1/account/verify_credentials.json":
return {
body: JSON.stringify({
id: 123,
screen_name: "na2hiro",
other: "info",
}),
body: "oauth_token=ACCESS_TOKEN&oauth_token_secret=ACCESS_TOKEN_SECRET&screen_name=na2hiro&user_id=123",
init: {
status: 200,
},
Expand All @@ -231,8 +219,8 @@ describe(TwitterStrategy, () => {
verify.mockImplementationOnce(
({ accessToken, accessTokenSecret, profile }) => {
return {
id: profile.id,
screen_name: profile.screen_name,
userId: profile.userId,
screenName: profile.screenName,
};
}
);
Expand All @@ -242,8 +230,8 @@ describe(TwitterStrategy, () => {
});

expect(user).toEqual({
id: 123,
screen_name: "na2hiro",
userId: "123",
screenName: "na2hiro",
});

expect(fetchMock.mock.calls[0][0]).toMatchInlineSnapshot(
Expand All @@ -257,10 +245,9 @@ describe(TwitterStrategy, () => {
accessToken: "ACCESS_TOKEN",
accessTokenSecret: "ACCESS_TOKEN_SECRET",
profile: {
id: 123,
screen_name: "na2hiro",
other: "info",
} as unknown as TwitterProfile,
userId: "123",
screenName: "na2hiro",
} as Profile,
} as TwitterStrategyVerifyParams);
});

Expand Down

0 comments on commit e2469f9

Please sign in to comment.