【GAS】OAuth2.0を用いたChatworkAPIリソースへのアクセス

この記事は、Chatworkに実装されているOAuth2.0を用いて、Chatwork API を Google Apps Script (GAS) で利用したい方に向けて書いています。

本記事では、以下の5点に重点を置いて解説しています。OAuth2.0の詳細までは触れていませんのでご注意ください。

- ChatworkでのOAuthクライアントの作成
- 認可コード取得のサンプルコード
- アクセストークン、リフレッシュトークン発行
- トークンでのChatworkリソースへのアクセス
- 取得したリソースをスプレッドシートに転記

---

Chatwork APIを利用する方法は、以下の3つの方法があります。
1. APIトークンを用いてアクセスする
2. OAuth2.0を用いてアクセスする
3. Webhookを利用する

今回、「2. OAuth2.0を用いてアクセスする」を選択したのは、[Chatworkの公式ページ](https://developer.chatwork.com/docs/endpoints)に「APIトークンは有効期限がなく、機能にフルアクセスが可能なものになっています。~~~」との記載があり、万が一APIトークンが漏洩した時の被害を最小限にするためOAuth2.0を選択しました。

※企業ドメインだと、Webhook使うには管理コンソールで穴あけが必要になるケースが多いかと思います。

また、この記事だけを読んで実装するのではなく、公式ドキュメント[Chatwork公式ページ(OAuth 2.0について)](https://developer.chatwork.com/docs/oauth)もしっかりと確認したうえで実装してください。

それでは早速、OAuth2.0を用いてGASからChatwork APIを利用する方法を見ていきます。

## Chatworkに実装されているOAuth2.0の仕様
- 認可フロー
    - [RFC6749](https://datatracker.ietf.org/doc/html/rfc6749) で定義されている認可フロー（Authorization Grant）のうち、
**Authorization Code Grant** にのみ対応しています。
Authorization Code Grantとは、認可エンドポイントに認可リクエストを投げ、応答として短命の認可コードを受けとり、その認可コードをトークンエンドポイントでアクセストークンと交換するフローです。

- 機能
    - 現在公開されているすべてのChatwork APIを利用できる

## クライアントプログラムからChatwork APIを利用する方法

### 1.クライアントの登録
1-1. Chatworkのホーム画面から右上のプロフィールを開き、「サービス連携」を選択
1-2. サービス連携ページで、左側のウインドウの中から「OAuth」を選択
1-3. 「新規作成」を選択。
上記の通り進むと、OAuthクライアントの新規作成画面にたどり着きます。
上から順に埋めていきましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3574641/01faef4e-1b47-529a-e0e5-93b0c72c7e57.png)

**1-4. クライアント名を登録**
アプリ名を使うのがシンプルで良いと思います。
今回は「test-chatwork-api-oauth-client」とか適当に入力します。

**1-5. クライアントタイプを選択**
今回は「企業ドメイン内の限られた公開範囲で、GASで実行する」前提で書いています。アクセス権を設定した人しかアクセス出来ないとはいえ、GASのコードはブラウザの開発者ツール等で簡単に見れてしまいますので、クレデンシャルを安全に秘匿出来ているとは言えません。したがって、パブリッククライアントに該当します。

**1-6. リダイレクト先URIの設定**
後の工程で認可コードが発行されたタイミングで、クエリパラメータに認可コードがセットされたうえでリダイレクトされます。
GASでリダイレクトを受け取り、クエリパラメータから認可コードを取得して、みたいな処理をするには、doGetメソッドを実装する必要があります。
doGetメソッドは、デプロイしてWebアプリとして扱う必要があるので、GASのエディタからデプロイして、そのURLを設定する必要があります。
当たり前の話ですが、GASはデプロイするたびURLは変わるので、コードを書き換えたら設定しているURIを都度更新する必要があります。

**1-7. スコープの設定**
ChatworkAPIを叩くときのスコープを設定します。最小権限の原則に従って適切なスコープを選択しましょう。
例えば、特定のルームのメッセージを取得したいだけであれば、「rooms.messages:read」（自分が参加しているチャットルームのメッセージ取得）を選択します。

以上の設定を終えたら、一番下の「作成」ボタンを押下してクライアント登録は終了です。
※おそらくGASのコーディングを終えたタイミングで、リダイレクトURIの再設定が必要になります。

### 2.認可コード（Authorization Code）の取得

ここからはいよいよGASの実装に入ります。
その前に、Chatwork APIをOAuthで利用する際の認可フローの全体像を確認しましょう。
登場するエンドポイントは、「クライアント」「認可サーバ」「リソースサーバ」の3つ。（リソース所有者はユーザー）

### 実際の認可フロー

```mermaid 
sequenceDiagram
    participant クライアント
    participant リソース所有者
    participant サーバ(認可)
    participant サーバ(リソース)
    
    activate リソース所有者
    リソース所有者->>サーバ(認可): OAuthクライアント登録(public_client)
    activate サーバ(認可)
    サーバ(認可)-->>リソース所有者: client_idを発行
    deactivate サーバ(認可)

    リソース所有者-->>クライアント: client_idを提供
    deactivate リソース所有者

    クライアント->>リソース所有者: 認可リクエスト用(コンセント画面)のURL発行
    note right of クライアント: code_verifierとcode_challenge生成
    activate リソース所有者

    リソース所有者->>サーバ(認可): 認可リクエスト(code_challenge送信)
    activate サーバ(認可)
    サーバ(認可)->>リソース所有者: コンセント画面表示
    リソース所有者->>サーバ(認可): 許可
    deactivate リソース所有者
    サーバ(認可)->>クライアント: 認可コード発行
    deactivate サーバ(認可)

    note over クライアント,サーバ(認可): 認可コードは有効期限1分間

    クライアント->>サーバ(認可): 認可コードを用いてアクセストークンを要求(code_verifier送信)
    activate サーバ(認可)
    サーバ(認可)->>クライアント: アクセストークン提供
    deactivate サーバ(認可)

    クライアント->>サーバ(リソース): API利用開始（アクセストークン（有効期限30分）を使用したBearer認証）
    note over クライアント,サーバ(リソース): スコープ:rooms.messages:read（自分が参加しているチャットルームのメッセージ取得）
    activate サーバ(リソース)
    サーバ(リソース)->>クライアント: APIレスポンス
    deactivate サーバ(リソース)

```

では、実際に認可コードを取得するコードを実装していきます。

**2-1. コンセント画面のURLを生成**
上のフローを見ると分かるように、認可リクエスト用のURLを生成し、そのURLからコンセント画面にアクセスする必要があります。
コンセント画面へのアクセスに必要なパラメータは7つ。

- response_type
- client_id
- redirect_uri（必須ではない）
- scope
- state（必須ではない）
- code_challenge（パブリッククライアントの場合は必須）
- code_challenge_method（パブリッククライアントの場合は必須）

7つのパラメータをセットしたURLを生成する必要があります。
※下記のURLはChatworkのドキュメントに記載されているサンプルです。
```
https://www.chatwork.com/packages/oauth2/login.php?
response_type=code
&redirect_uri=https://example.com/callback.php
&client_id=Lvo0YN92ga5kP
&state=811435b3683ae95c1cf3197deaf1bfe4b411f587
&scope=rooms.all:read_write%20users.profile.me:read
&code_challenge=jlkGAsNvHshJNC7uXSSmC2tALONajPdupVf3TScb7zk
&code_challenge_method=S256
```

ちなみに、state、code_challengeはそれぞれCSRF攻撃対策と認可コード横取り攻撃の対策です。この記事内で詳しい説明はしないので、別途調べてください。

ここからはサンプルコード。
基本的にclient_idとかはスクリプトプロパティで保持し、ハードコードしないようにしましょう。

code_verifier、code_challengeは、Chatworkのドキュメントに従った形式で生成します。

```
const properties = PropertiesService.getScriptProperties();

/**
 * 認可コードを取得するために、Chatwork API コンセント画へのURLを生成する関数。
 */
function getAuthorizationCode() {

  const redirectUri = properties.getProperty('REDIRECT_URI');
  const clientId = properties.getProperty('CLIENT_ID');
  const scope = properties.getProperty('SCOPE');

  //stateを生成し、スクリプトプロパティに上書き
  const state = generateStateParameter();
  properties.setProperty('STATE', state);

  /* code_verifierを生成し、スクリプトプロパティに上書き。
   文字列長は43文字から128文字、文字種としては正規表現で [a-zA-Z0-9-._~]+ に該当する文字列。*/
  const codeVerifier = generateCodeVerifier(43);
  properties.setProperty('CODE_VERIFIER', codeVerifier);

  /*code_challengeを生成
  code_verifier の値を、SHA-256でハッシュ値をとり、そのハッシュ値をURLセーフなBase64エンコード(パディングなし)。*/
  const codeChallenge = generateCodeChallenge(codeVerifier);

  //コンセント画面へのURL構築
  const authorizationUrl = 'https://www.chatwork.com/packages/oauth2/login.php' +
    '?response_type=code' +
    '&redirect_uri=' + redirectUri +
    '&client_id=' + clientId +
    '&state=' + state +
    '&scope=' + scope +
    '&code_challenge=' + codeChallenge +
    '&code_challenge_method=S256';

  SpreadsheetApp.getUi().alert(
    "認可コード取得URLを生成しました。\n" +
    "次のURLをアドレスバーに入力してください。\n\n" +
    authorizationUrl,
    SpreadsheetApp.getUi().ButtonSet.OK
  );
}


/**
 * ランダムな code_verifier を生成する関数
 * 
 * @param {number} length 生成するランダム文字列の長さ
 * @return {string} 生成したランダム文字列
 */
function generateCodeVerifier(length) {
  const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
  let result = '';
  for (let i = 0; i < length; i++) {
    result += charset.charAt(Math.floor(Math.random() * charset.length));
  }
  return result;
}


/**
 * code_challenge を生成する関数
 * 
 * @param {string} codeVerifier ランダムに生成した code_verifier
 * @return {string} 生成した code_challenge
 */
function generateCodeChallenge(codeVerifier) {
  const hashBuffer = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, codeVerifier);
  return Utilities.base64EncodeWebSafe(hashBuffer).replace(/=+$/, '');
}


/**
 * CSRF対策用のランダムな state パラメータを生成する関数
 * 
 * @return {string} 生成した state パラメータ
 */
function generateStateParameter() {
  const randomString = Math.random().toString(36).substring(2, 18) + Math.random().toString(36).substring(2, 18);
  return randomString;
}
```

このコードにより、getAuthorizationCode()関数を実行すると、コンセント画面へのURLが記載されたダイアログボックスが表示されるので、ユーザーはそのURLからコンセント画面へアクセスします。

コンセント画面でChatwork側の認証を行うことで、この時アクセスした人の権限でAPIリクエストを行うことができるようになります。
（画面のスクショを撮るの忘れていましたが、表示されているアカウントとスコープに間違いがないことを確認して、「許可」ボタンを押せばOKです）

コンセント画面で許可をすると、認可コードと、コンセント画面にアクセスしたときのクエリパラメータに含んでいたstateがクエリパラメータにセットされた状態で、設定したリダイレクトURIにリダイレクトされます。（↓こんな感じで。もちろん値はダミー）
```
https://example.com/callback.php?
code=a2f0c1fe96af8c3a46fa0
&state=811435b3683ae95c1cf3197deaf1bfe4b411f587
```

リダイレクトURLから認可コードを取得して、スクリプトプロパティに上書きする処理は以下のように実装出来ます。
getTokens()関数は、アクセストークンとリフレッシュトークンを発行するための関数なので、次の章で解説します。

```
/**
 * Chatworkからのリダイレクトを受け取り、認可コードを取得する関数
 */
function doGet(e) {

  const authorizationCode = e.parameter.code;
  const state = e.parameter.state;

  //CSRF対策
  if (state !== PropertiesService.getScriptProperties().getProperty('STATE')) {
    const errorMessage = `認可リクエスト時のstateとリダイレクトリクエストパラメータのstateが一致しません。\n詳細情報: ${JSON.stringify(e)}`;
    Logger.log(errorMessage);
    return HtmlService.createHtmlOutput(errorMessage);
  }

  if (authorizationCode) {

    const codeVerifier = PropertiesService.getScriptProperties().getProperty('CODE_VERIFIER');
    const response = getTokens(authorizationCode, codeVerifier);
    PropertiesService.getScriptProperties().setProperty('ACCESS_TOKEN', response.access_token);
    PropertiesService.getScriptProperties().setProperty('REFRESH_TOKEN', response.refresh_token);

    return HtmlService.createHtmlOutput('認証に成功しました。このページを閉じてください。');

  } else {
    Logger.log('リクエストパラメータに認可コードが存在しません。');
    return HtmlService.createHtmlOutput('認証に失敗しました。');
  }
}
```


アクセストークンとリフレッシュトークンを発行してもらう処理の構築をしていきます。

### 3.アクセストークンの発行/再発行

アクセストークンを発行してもらうには、以下のエンドポイントにHTTP POSTメソッドでアクセスします。
この時のリクエストボディパラメータは以下の通り。

【認可コードから発行する場合（初回）】

- grant_type: authorization_code を指定
- code: 認可コードを指定
- redirect_uri: コンセント画面を表示する際に redirect_uri を指定している場合はトークンを発行する際も指定
- code_verifier: 認可リクエストで、code_challenge, code_challenge_method を指定した場合は必須

【リフレッシュトークンを使って再発行する場合（2回目以降）】

- grant_type: refresh_token を指定
- refresh_token: トークン発行時に生成された refresh_token の値を指定
- scope: 最初に設定したスコープを指定

curlコマンドからリクエストする場合のサンプルは以下。
（公式ドキュメントより引用。値はもちろんダミーです！！）

```
# パブリッククライアントの場合
curl -v  -X POST -d 'grant_type=authorization_code \
&client_id=Lvo0YN92ga5kP \
&code=26d13798facc9a0ca05a8cb7246020f15a311 \
&redirect_uri=https://127.0.0.1/callback
&code_verifier=5b0029bd34e559e0abe7a37051aa411398913fc3579e27bd963a2b9a647f12f58a335beeb4d83a53a74ff1a6f99f6af385d2992c73beead39f57dcee95e0f954' \
https://oauth.chatwork.com/token
```

サンプルの通りに、ペイロード部に指定された値をセットして、トークン取得用のエンドポイントにPOSTリクエストを送り、レスポンス（JSON）をdoGet()関数に返します。doGet()関数では、レスポンスのaccess_token要素とrefresh_token要素をスクリプトプロパティに上書きします。

```
/**
 * アクセストークンとリフレッシュトークンを取得する関数
 * 
 * @param {string} authorizationCode 認可コード
 * @param {string} codeVerifier 認可リクエスト時に生成したcode_verifier
 * @return {Object} 取得したアクセストークンとリフレッシュトークンを含むオブジェクト
 */
function getTokens(authorizationCode, codeVerifier) {
  const clientId = PropertiesService.getScriptProperties().getProperty('CLIENT_ID');
  const redirectUri = PropertiesService.getScriptProperties().getProperty('REDIRECT_URI');
  const tokenEndpoint = 'https://oauth.chatwork.com/token';

  const params = {
    'grant_type': 'authorization_code',
    'code': authorizationCode,
    'client_id': clientId,
    'redirect_uri': redirectUri,
    'code_verifier': codeVerifier
  };

  const options = {
    'method': 'post',
    'payload': params,
    'headers': {
      'Content-Type': 'application/x-www-form-urlencoded'
    }
  };

  try {
    const response = UrlFetchApp.fetch(tokenEndpoint, options);
    const responseData = JSON.parse(response.getContentText());
    return responseData;
  } catch (error) {
    Logger.log('アクセストークンの取得に失敗しました。');
    Logger.log(error);
    throw new Error('アクセストークンの取得に失敗しました。');
  }
}
```

上記のコードとほぼ同じですが、リフレッシュトークンを使ってアクセストークンを再発行する場合のコードは以下のように実装出来ます。
アクセストークンの有効期限は30分であり、筆者は1時間毎にトリガーしてメッセージを自動取得しているので、基本このupdateTokens()関数が回り続けます。

```
/**
 * リフレッシュトークンを使用してアクセストークンを再発行する関数
 */
function updateTokens() {
  const clientId = PropertiesService.getScriptProperties().getProperty('CLIENT_ID');
  const refreshToken = PropertiesService.getScriptProperties().getProperty('REFRESH_TOKEN');
  const tokenEndpoint = 'https://oauth.chatwork.com/token';

  const params = {
    'grant_type': 'refresh_token',
    'refresh_token': refreshToken,
    'client_id': clientId,
    'scope': properties.getProperty('SCOPE')
  };

  const options = {
    'method': 'post',
    'payload': params,
    'headers': {
      'Content-Type': 'application/x-www-form-urlencoded'
    }
  };

  try {
    const response = UrlFetchApp.fetch(tokenEndpoint, options);
    const responseData = JSON.parse(response.getContentText());

    PropertiesService.getScriptProperties().setProperty('ACCESS_TOKEN', responseData.access_token);
    PropertiesService.getScriptProperties().setProperty('REFRESH_TOKEN', responseData.refresh_token);
    
  } catch (error) {
    Logger.log('アクセストークンの更新に失敗しました。');
    Logger.log(error);
    throw new Error('アクセストークンの更新に失敗しました。');
  }
}
```

以上で、認可コードをトークンエンドポイントに送り、アクセストークンとリフレッシュトークンを発行してもらう構築が出来ました。
ここからはトークンベースでのAPI連携が出来るようになるので、Gmailと連携するなり、スプレッドシートにデータを積み上げるなり、タスクに合わせて実装していただければいいと思います。

### 4. トークンでのChatworkリソースへのアクセス

以下は、特定のルームのメッセージを取得してスプレッドシートに積み上げるサンプルコードです。

```
/**
 * 実行メイン関数
 *  - リソース取得前にトークンを更新
 *  - 構築したURL(特定のルーム)に対してGETリクエスト
 *  - レスポンスをハンドリング関数に渡す
 */
function getChatworkMessages() {

  //アクセストークンの有効期限は30分なので、リソース取得の前にトークンを更新する。
  updateTokens();

  const latestAccessToken = PropertiesService.getScriptProperties().getProperty('ACCESS_TOKEN');
  const roomId = PropertiesService.getScriptProperties().getProperty('ROOM_ID');

  /*クエリパラメータの force= の値は、強制的に最大件数まで取得するかどうか。
    0を指定した場合（既定）は前回取得分からの差分のみを返しますが、
    1を指定した場合は強制的に最新のメッセージを最大100件まで取得します。*/
  const requestUrl = `https://api.chatwork.com/v2/rooms/${roomId}/messages?force=1`;

  const options = {
    method: 'GET',
    headers: {
      accept: 'application/json',
      authorization: 'Bearer ' + latestAccessToken
    }
  };

  try {
    const response = UrlFetchApp.fetch(requestUrl, options);
    handleResponse(response);
  } catch (error) {
    Logger.log("リクエストの実行中にエラーが発生しました: " + error.message);
  }
}


/**
 * レスポンスをハンドリングする関数
 * レスポンスコードは（https://developer.chatwork.com/reference/get-rooms-room_id-messages）を参照。
 * 
 * @param {HTTPResponse} response - ChatworkAPIからのHTTPレスポンスオブジェクト
 */
function handleResponse(response) {
  const responseCode = response.getResponseCode();
  const responseData = JSON.parse(response.getContentText());

  switch (responseCode) {
    case 200:
      parseAndOutputTheMessages(responseData);
      break;
    case 204:
      Logger.log("メッセージがありません。");
      break;
    case 400:
      Logger.log("リクエストまたはアクセストークンのパラメータが不足している、および不正な値が指定されています。");
      break;
    case 401:
      Logger.log("認証に失敗しました。");
      break;
    case 403:
      Logger.log("チャットのメッセージを取得する権限がありません。");
      break;
    case 429:
      Logger.log("APIの利用回数制限を超過しました。");
      break;
    default:
      Logger.log("予期しないレスポンスコード: " + responseCode);
      break;
  }
}


/**
 * レスポンスをスプレッドシートに出力する関数
 *  ◆◆◆テスト版なので重複削除とかの機能は無し。最終行への追加のみ。◆◆◆
 * 
 * @param {Array} responseData - ChatworkAPIからのメッセージデータの配列
 */
function parseAndOutputTheMessages(responseData) {
  const messageHistorySheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('メッセージ履歴');

  responseData.forEach(function (message) {
    let updateTime = message.update_time === 0 ? "-" : new Date(message.update_time * 1000);
    let rowData = [
      message.message_id,
      new Date(message.send_time * 1000),
      updateTime, 
      message.account.name,
      message.body
    ];
    messageHistorySheet.appendRow(rowData);
  });
}

```

ここで、前半に「~~コードを書き換えたら設定しているURIを都度更新する必要があります。」と書いた通り、開発が終わったら最新版をデプロイして、OAuthクライアントに登録しているリダイレクトURLを忘れずに更新してください！忘れると古いコードが実行され続けて謎エラーが頻発することになります！
（筆者はこの作業を忘れていたためエラー特定に１時間費やしました.....）


最後までお読みいただきありがとうございました。
実装にあたり調査しているときに非常に勉強になった記事を参考リンクにまとめますので、ぜひご一読ください。

## 参考リンク

- [PKCE なにそれ？おいしいの？](https://zenn.dev/hitoe_kami/articles/0060-articles-what-pkce-is)
- [PKCEにおけるcode_challenge生成について](https://qiita.com/nannany_stores/items/b30ac759553661dfb265)
- [ChatworkのOAuthフローをDockerを使ってPoCする](https://qiita.com/tenda_ryo_y/items/8e16f0b508170f9ab230)
- [OAuth 2.0 全フローの図解と動画](https://qiita.com/TakahikoKawasaki/items/200951e5b5929f840a1f)
- [OAuth2.0のAuthorization Code Grantでstateが必要な理由を理解する](https://qiita.com/yuta-katayama-23/items/020169b66d1abe242b37)