|
| 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