Skip to content

Commit 556c8d6

Browse files
authored
Implement OAuth2 (Twitter2Strategy) (#27)
1 parent 9fb6fee commit 556c8d6

File tree

8 files changed

+964
-401
lines changed

8 files changed

+964
-401
lines changed

src/Twitter1Strategy.ts

Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
import {
2+
AppLoadContext,
3+
json,
4+
redirect,
5+
SessionStorage,
6+
} from "@remix-run/server-runtime";
7+
import createDebug from "debug";
8+
import {
9+
AuthenticateOptions,
10+
Strategy,
11+
StrategyVerifyCallback,
12+
} from "remix-auth";
13+
import { v4 as uuid } from "uuid";
14+
import hmacSHA1 from "crypto-js/hmac-sha1";
15+
import Base64 from "crypto-js/enc-base64";
16+
import { fixedEncodeURIComponent } from "./utils";
17+
18+
let debug = createDebug("TwitterStrategy");
19+
20+
const requestTokenURL = "https://api.twitter.com/oauth/request_token";
21+
const authorizationURL = "https://api.twitter.com/oauth/authorize";
22+
const authenticationURL = "https://api.twitter.com/oauth/authenticate";
23+
const tokenURL = "https://api.twitter.com/oauth/access_token";
24+
25+
export interface Twitter1StrategyOptions {
26+
clientID: string;
27+
clientSecret: string;
28+
callbackURL: string;
29+
alwaysReauthorize?: boolean;
30+
}
31+
32+
export interface Profile {
33+
userId: string;
34+
screenName: string;
35+
}
36+
37+
export interface Twitter1StrategyVerifyParams {
38+
accessToken: string;
39+
accessTokenSecret: string;
40+
profile: Profile;
41+
context?: AppLoadContext;
42+
}
43+
44+
export const Twitter1StrategyDefaultName = "twitter1";
45+
46+
/**
47+
* Twitter's OAuth 1.0a login
48+
*
49+
* Applications must supply a `verify` callback, for which the function signature is:
50+
*
51+
* function({accessToken, accessTokenSecret, profile}) { ... }
52+
*
53+
* The verify callback is responsible for finding or creating the user, and
54+
* returning the resulting user object to be stored in session.
55+
*
56+
* An AuthorizationError should be raised to indicate an authentication failure.
57+
*
58+
* Options:
59+
* - `clientID` identifies client to service provider
60+
* - `clientSecret` secret used to establish ownership of the client identifier
61+
* - `callbackURL` URL to which the service provider will redirect the user after obtaining authorization
62+
* - `alwaysReauthorize` If set to true, always as app permissions. This was v1 behavior.
63+
* If false, just let them login if they've once accepted the permission. (optional. default: false)
64+
*
65+
* @example
66+
* authenticator.use(new TwitterStrategy(
67+
* {
68+
* clientID: '123-456-789',
69+
* clientSecret: 'shhh-its-a-secret',
70+
* callbackURL: 'https://www.example.net/auth/example/callback',
71+
* },
72+
* async ({ accessToken, accessTokenSecret, profile }) => {
73+
* return await User.findOrCreate(profile.id, profile.email, ...);
74+
* }
75+
* ));
76+
*/
77+
export class Twitter1Strategy<User> extends Strategy<
78+
User,
79+
Twitter1StrategyVerifyParams
80+
> {
81+
name = Twitter1StrategyDefaultName;
82+
83+
protected clientID: string;
84+
protected clientSecret: string;
85+
protected callbackURL: string;
86+
protected alwaysReauthorize: boolean;
87+
88+
constructor(
89+
options: Twitter1StrategyOptions,
90+
verify: StrategyVerifyCallback<User, Twitter1StrategyVerifyParams>
91+
) {
92+
super(verify);
93+
this.clientID = options.clientID;
94+
this.clientSecret = options.clientSecret;
95+
this.callbackURL = options.callbackURL;
96+
this.alwaysReauthorize = options.alwaysReauthorize || false;
97+
}
98+
99+
async authenticate(
100+
request: Request,
101+
sessionStorage: SessionStorage,
102+
options: AuthenticateOptions
103+
): Promise<User> {
104+
debug("Request URL", request.url.toString());
105+
let url = new URL(request.url);
106+
let session = await sessionStorage.getSession(
107+
request.headers.get("Cookie")
108+
);
109+
110+
let user: User | null = session.get(options.sessionKey) ?? null;
111+
112+
// User is already authenticated
113+
if (user) {
114+
debug("User is authenticated");
115+
return this.success(user, request, sessionStorage, options);
116+
}
117+
118+
let callbackURL = this.getCallbackURL(url);
119+
debug("Callback URL", callbackURL.toString());
120+
121+
// Before user navigates to login page: Redirect to login page
122+
if (url.pathname !== callbackURL.pathname) {
123+
// Unlike OAuth2, we first hit the request token endpoint
124+
const { requestToken, callbackConfirmed } = await this.fetchRequestToken(
125+
callbackURL
126+
);
127+
128+
if (!callbackConfirmed) {
129+
throw json(
130+
{ message: "Callback not confirmed" },
131+
{
132+
status: 401,
133+
}
134+
);
135+
}
136+
137+
// Then let user authorize the app
138+
throw redirect(this.getAuthURL(requestToken).toString(), {
139+
headers: {
140+
"Set-Cookie": await sessionStorage.commitSession(session),
141+
},
142+
});
143+
}
144+
145+
// Validations of the callback URL params
146+
147+
const denied = url.searchParams.get("denied");
148+
if (denied) {
149+
debug("Denied");
150+
return await this.failure(
151+
"Please authorize the app",
152+
request,
153+
sessionStorage,
154+
options
155+
);
156+
}
157+
const oauthToken = url.searchParams.get("oauth_token");
158+
if (!oauthToken)
159+
throw json(
160+
{ message: "Missing oauth token from auth response." },
161+
{ status: 400 }
162+
);
163+
const oauthVerifier = url.searchParams.get("oauth_verifier");
164+
if (!oauthVerifier)
165+
throw json(
166+
{ message: "Missing oauth verifier from auth response." },
167+
{ status: 400 }
168+
);
169+
170+
// Get the access token
171+
let params = new URLSearchParams();
172+
params.set("oauth_token", oauthToken);
173+
params.set("oauth_verifier", oauthVerifier);
174+
175+
let { accessToken, accessTokenSecret, ...profile } =
176+
await this.fetchAccessTokenAndProfile(params);
177+
178+
// Verify the user and return it, or redirect
179+
try {
180+
user = await this.verify({
181+
accessToken,
182+
accessTokenSecret,
183+
profile,
184+
context: options.context,
185+
});
186+
} catch (error) {
187+
debug("Failed to verify user", error);
188+
let message = (error as Error).message;
189+
return await this.failure(message, request, sessionStorage, options);
190+
}
191+
192+
debug("User authenticated");
193+
return await this.success(user, request, sessionStorage, options);
194+
}
195+
196+
private getCallbackURL(url: URL) {
197+
if (
198+
this.callbackURL.startsWith("http:") ||
199+
this.callbackURL.startsWith("https:")
200+
) {
201+
return new URL(this.callbackURL);
202+
}
203+
if (this.callbackURL.startsWith("/")) {
204+
return new URL(this.callbackURL, url);
205+
}
206+
return new URL(`${url.protocol}//${this.callbackURL}`);
207+
}
208+
209+
private static generateNonce() {
210+
return uuid();
211+
}
212+
213+
private static generateTimestamp() {
214+
return `${Math.floor(Date.now() / 1000)}`;
215+
}
216+
217+
/**
218+
* Step 1: oauth/request_token
219+
*/
220+
private async fetchRequestToken(callbackUrl: URL): Promise<{
221+
requestToken: string;
222+
requestTokenSecret: string;
223+
callbackConfirmed: boolean;
224+
}> {
225+
const parameters = this.signRequest(
226+
{ oauth_callback: callbackUrl.toString() },
227+
"GET",
228+
requestTokenURL
229+
);
230+
const url = new URL(requestTokenURL);
231+
url.search = new URLSearchParams(parameters).toString();
232+
const urlString = url.toString();
233+
debug("Fetching request token", urlString);
234+
let response = await fetch(urlString, {
235+
method: "GET",
236+
});
237+
238+
if (!response.ok) {
239+
let body = await response.text();
240+
throw new Response(body, { status: 401 });
241+
}
242+
const text = await response.text();
243+
const body: { [key: string]: string } = {};
244+
for (const pair of text.split("&")) {
245+
const [key, value] = pair.split("=");
246+
body[key] = value;
247+
}
248+
249+
return {
250+
requestToken: body.oauth_token as string,
251+
requestTokenSecret: body.oauth_token_secret as string,
252+
callbackConfirmed: body.oauth_callback_confirmed === "true",
253+
};
254+
}
255+
256+
/**
257+
* Generate signature with HMAC-SHA1 algorithm
258+
*/
259+
signRequest(
260+
headers: { [key: string]: string },
261+
method: "GET" | "POST",
262+
url: string,
263+
accessTokenSecret?: string
264+
) {
265+
const params = {
266+
...headers,
267+
oauth_consumer_key: this.clientID,
268+
oauth_nonce: Twitter1Strategy.generateNonce(),
269+
oauth_timestamp: Twitter1Strategy.generateTimestamp(),
270+
oauth_version: "1.0",
271+
oauth_signature_method: "HMAC-SHA1",
272+
};
273+
// Convert to "key=value, key=value" format
274+
const parameters = Object.entries(params)
275+
.sort(([k1], [k2]) => k1.localeCompare(k2))
276+
.map(
277+
([key, value]) =>
278+
`${fixedEncodeURIComponent(key)}=${fixedEncodeURIComponent(value)}`
279+
)
280+
.join("&");
281+
const signature_base = `${method}&${fixedEncodeURIComponent(
282+
url
283+
)}&${fixedEncodeURIComponent(parameters)}`;
284+
const signing_key = `${this.clientSecret}&${accessTokenSecret || ""}`;
285+
const signed = Base64.stringify(hmacSHA1(signature_base, signing_key));
286+
return {
287+
...params,
288+
oauth_signature: signed,
289+
oauth_signature_method: "HMAC-SHA1",
290+
};
291+
}
292+
293+
/**
294+
* Step 2: Let user authorize
295+
*/
296+
private getAuthURL(requestToken: string) {
297+
let params = new URLSearchParams();
298+
params.set("oauth_token", requestToken);
299+
300+
let url = new URL(
301+
this.alwaysReauthorize ? authorizationURL : authenticationURL
302+
);
303+
url.search = params.toString();
304+
305+
return url;
306+
}
307+
308+
/**
309+
* Step 3: Fetch access token to do anything
310+
*/
311+
private async fetchAccessTokenAndProfile(params: URLSearchParams): Promise<{
312+
accessToken: string;
313+
accessTokenSecret: string;
314+
userId: string;
315+
screenName: string;
316+
}> {
317+
params.set("oauth_consumer_key", this.clientID);
318+
319+
debug("Fetch access token", tokenURL, params.toString());
320+
let response = await fetch(tokenURL, {
321+
method: "POST",
322+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
323+
body: params,
324+
});
325+
326+
if (!response.ok) {
327+
let body = await response.text();
328+
// TODO: ここにくる
329+
debug("error! " + body);
330+
throw new Response(body, { status: 401 });
331+
}
332+
333+
return await this.extractAccessTokenAndProfile(
334+
response.clone() as unknown as Response
335+
);
336+
}
337+
338+
protected async extractAccessTokenAndProfile(response: Response): Promise<{
339+
accessToken: string;
340+
accessTokenSecret: string;
341+
userId: string;
342+
screenName: string;
343+
}> {
344+
const text = await response.text();
345+
const obj: { [key: string]: string } = {};
346+
for (const pair of text.split("&")) {
347+
const [key, value] = pair.split("=");
348+
obj[key] = value;
349+
}
350+
return {
351+
accessToken: obj.oauth_token as string,
352+
accessTokenSecret: obj.oauth_token_secret as string,
353+
userId: obj.user_id as string,
354+
screenName: obj.screen_name as string,
355+
} as const;
356+
}
357+
}

0 commit comments

Comments
 (0)