🔓

JestでTwitter OAuth 2.0の認可コード取得処理を自動化する

2022/03/05に公開

前提

  • TwitterのOAuth 2.0 Authorization Code Flow with PKCEを利用
    • + Confidential Clientを利用(not Public Client)
  • Confidential ClientのHTTP APIのE2Eテストで必要な認可コード取得処理を自動化

自動テストしたい箇所

TwitterのOAuth 2.0 Authorization Code Flow with PKCEは、以下のようなフローとなります。詳細は、Developer Platformが参考になります

img

plantuml
@startuml
title Twitter OAuth 2.0 Authorization Code Flow with PKCE
actor "User" as user
box "YourWebApp" #dee6ee
  participant "トップページ" as webAppTopPage
end box
box "YourServerSide" #e6e6fa
  participant "HTTP API" as httpApi
end box
box "Twitter" #2bc4ff
  participant "Twitter認可画面" as twAuth
  participant "Twitter認可サーバー" as twServer
  participant "TwitterAPI" as twApi
end box
activate webAppTopPage
activate user
user->webAppTopPage: login with Twitterを押下
webAppTopPage->webAppTopPage: code_verifier生成
webAppTopPage->webAppTopPage: code_verifierからcode_challenge生成
webAppTopPage->twAuth: code_challenge
activate twAuth
twAuth->twAuth: 認可画面を表示
twAuth-->user
deactivate twAuth
user->user: 「アプリにアクセスを許可」を押下
user->twServer
activate twServer
twServer-->twServer: YourWebAppのコールバックURLへリダイレクト
twServer-->webAppTopPage: authraization_code
deactivate twServer
webAppTopPage->httpApi: authraization_code, code_verifier
activate httpApi
httpApi->twServer: authraization_code, code_verifier, clientSecret
activate twServer
twServer-->httpApi: accessToken
deactivate twServer
httpApi->twApi: 認可情報の取得
activate twApi
twApi-->httpApi: 取得OK
@enduml

図のYourServerSideのHTTP APIでは、認可コード(authraization_code)を受け取る必要があり、この認可コード生成は手動でポチポチやるのが結構面倒です。今回は、この手動ポチポチをpuppeteerjestを利用して自動化します。

実装

コメントにて補足します

puppeteerを起動し、認可画面を押下するコード
import * as Puppeteer from 'puppeteer';
import * as chrome from 'chrome-cookies-secure';

// PKCEに必要なcodeVerifier,codeChallenge生成する関数です(後述)
import { generateCodeChallenge, generateRandomString } from './text';

const codeVerifier = generateRandomString(45);
const codeChallenge = generateCodeChallenge(codeVerifier);

const COOKIE_URL = 'https://twitter.com';
// 本コードで指定したChromeのプロファイルでログイン済みのTwitterアカウントでアプリに対し認可を与えることになります
// Chromeのプロファイル情報は、chrome://versionで確認できます
const CHROME_PROFILE_NAME = 'Default';
const AUTH_BUTTON_SELECTOR = '<認可ボタンのセレクター>'
const TWITTER_AUTH_ENDPOINT =
  'https://twitter.com/i/oauth2/authorize?response_type=code&' +
  'client_id=<ClientId>&' +
  'redirect_uri=<リダイレクトURL>&' + // listenしてないとNG(*1)
  'scope=tweet.read%20users.read%20offline.access&' +
  'state=<state>&' +
  `code_challenge=${codeChallenge}&` +
  'code_challenge_method=s256';
const JEST_TIMEOUT_MILLSECS = 5 * 60 * 1000;

jest.setTimeout(JEST_TIMEOUT_MILLSECS);

interface Cookie {
  name: string;
  value: string;
  expires: number;
  domain: string;
  path: string;
  Secure: boolean;
}

// 通常puppeteerはsandboxを立ち上げるため、Twitterへログインし直す必要がありますが、今回は省略する作りになっています
// 第一引数にChromeのプロファイルを指定し、第二引数にプロファイルで取得するCookie情報のURLを指定します
const getCookies = (profile: string, url: string): Promise<Cookie[]> => {
  return new Promise((resolve, reject) => {
    chrome.getCookies(
      url,
      'puppeteer',
      (err: Error, cookies: Cookie[]) => {
        err ? reject(err) : resolve(cookies);
      },
      profile,
    );
  });
};

export const readyTwitterAuthGrant = async () => {
  const browser = await Puppeteer.launch({
    headless: false,
  });
  // https://twitter.comのCookie情報を取得
  const cookies = await getCookies(CHROME_PROFILE_NAME, COOKIE_URL);
  const page = await browser.newPage();
  // puppeteerのsandbox環境にCookie情報をセット
  await page.setCookie(...cookies);
  await page.goto(TWITTER_AUTH_ENDPOINT);

  const authButton = await page.waitForSelector(AUTH_BUTTON_SELECTOR);
  if (!authButton) {
    throw new Error('AUTH_BUTTON_SELECTOR changed?');
  }
  await authButton.click(); // YourWebAppへリダイレクト
  await page.waitForNavigation(); // リダイレクト待ち
  const redirectUri = page.url(); // リダイレクトURLを取得
  const url = new URL(redirectUri);
  const tmpState = url.searchParams.get('state');
  const tmpCode = url.searchParams.get('code'); // 認可コード取得
  if (!(tmpState && tmpCode)) {
    throw new Error();
  }

  return {
    data: {
      authCode: tmpCode,
      codeVerifier: codeVerifier,
    },
    tearDown: async () => {
      await browser.close();
    },
  };
};

*1 YourWebAppにリダイレクトするURLが非到達の場合は、以下コードで両対応可

コードを展開する
const redirectUri = await (async (): Promise<string> => {
  const isValidUrl = (url: string) => {
    const parsedEventUrl = new URL(url);
    if (parsedEventUrl.host === REDIRECT_HOST) {
      return true;
    }
    return false;
  };

  return new Promise((resolve) => {
    // リダイレクト先が非到達なURLの場合
    page.on('requestfailed', async (req) => {
      const eventUrl = req.url();
      if (isValidUrl(eventUrl)) {
        resolve(eventUrl);
      }
    });
    // リダイレクト先が到達可能なURLの場合
    page.on('response', async (res) => {
      const eventUrl = res.url();
      if (isValidUrl(eventUrl)) {
        resolve(eventUrl);
      }
    });
  });
})();
codeVerifier,codeChallengeを生成する関数
import * as crypto from 'crypto';

export const generateRandomString = (length: number) => {
  let text = '';
  const possible =
    'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';

  for (let i = 0; i < length; i++) {
    text += possible.charAt(Math.floor(Math.random() * possible.length));
  }

  return text;
};

export const generateCodeChallenge = (codeVerifier: string) => {
  const challenge = crypto
    .createHash('sha256')
    .update(codeVerifier)
    .digest('base64')
    .replace(/=/g, '')
    .replace(/\+/g, '-')
    .replace(/\//g, '_');

  return challenge;
};
認可コードを送信する先のHTTP APIのテストコード
import { readyTwitterAuthGrant } from './helpers/auth';

let data: { authCode: string; codeVerifier: string };
let tearDown: () => Promise<void>;

beforeAll(async () => {
  const res = await readyTwitterAuthGrant();
  data = res.data;
  tearDown = res.tearDown;
});

afterAll(async () => {
  await tearDown(); // sandobxの停止
});

describe('認可APIへauthCodeを送信する', () => {
  test('Return 200', async () => {
    // data.authCode, data.codeVerifierをHTTP APIに送信し、レスポンスを確認(省略)
  });
});

課題

  • headlessにすると失敗する

最後に

とりあえず快適にテストできそうです。もっと良い方法ありそうな気がしてならないですが、、ありましたらご教授いただけると嬉しいです。

参考

Twitter公式と以下の記事でフローに関しての理解が深まりました。ありがとうございます!

Discussion