Google Apps ScriptにおけるLINE WORKS API 2.0の認証

# はじめに
Google Apps Script (以下GAS) でLINE WORKS API 2.0を遊んでみた。
既にライブラリ化までしてくださっている方[^1]がいるけども、JWT認証については軽く触れるくらいだったので、ここではJWT認証に焦点をあてようと思った。

記事はめちゃくちゃ参考になった。
なお、Botの作成方法は省略。

https://qiita.com/kunihiros/items/4cdec5784f21598cfee0

https://qiita.com/kunihiros/items/caac7a00a6cd745fff1c


# 前提条件
- Google アカウント
- LINE WORKS管理者アカウント
- LINE WORKSアプリ情報の準備
    - Client ID
    - Client Secret
    - Service Account
    - Private Key
    - OAuth Scopes


# JWTの生成
LINE WORKS DevelopersのService Account認証 (JWT)[^2]に基づく。最新情報は公式ドキュメントを参照のこと。

ここでは、以下のようにheader、JSON Claim set、signatureをそれぞれ「.」で結合させたものを生成したい。
`{header BASE64エンコード}.{JSON Claim set BASE64エンコード}.{signature BASE64エンコード}`


## header BASE64エンコード
headerにてRSA SHA-256アルゴリズムを明示して、BASE64エンコードをする。ここは固定値。
```javascript
const header = Utilities.base64Encode(JSON.stringify({ 'alg': 'RS256', 'typ': 'JWT' }));
```

## JSON Claim set BASE64エンコード
以下のようにキーと値のオブジェクトを生成する。
```例.js
{
  'iss': 'ZbsOq6zjt0IhtZZnrc', // Client ID
  'sub': '1wagx.serviceaccount@example.com', // Service Account
  'iat': 1634711358, // JWT生成日時 UNIX時間で指定(単位:sec)
  'exp': 1634714958 // JWT満了日時 UNIX時間で指定(単位:sec)
}
```
`config`から情報を取得するようにしたいので、以下のようにした。
```javascript
const time = Date.now();
const claimSet = Utilities.base64Encode(JSON.stringify({
    'iss': config.CLIENT_ID,
    'sub': config.SERVICE_ACCOUNT,
    'iat': Math.floor(time / 1000), // ミリ秒はカット
    'exp': Math.floor(time / 1000 + 3600) // 生成日時から1時間後
}));
```

## signature BASE64エンコード
上記で生成した`{header BASE64エンコード}`と`{JSON Claim set BASE64エンコード}`を「.」で結合して、電子署名をしたのちに、BASE64エンコードをする。`JSON Claim set`のときと同様に`config`からPrivate Keyを取得する前提。
```javascript
const signature = Utilities.base64Encode(Utilities.computeRsaSha256Signature(`${headers}.${claimSet}`, config.PRIVATE_KEY));
```

## 結合
```javascript
const jwt = `${header}.${claimSet}.${signature}`;
```


- [base64Encode(data)](https://developers.google.com/apps-script/reference/utilities/utilities#base64encodedata_1)
- [computeRsaSha256Signature(value, key)](https://developers.google.com/apps-script/reference/utilities/utilities#computersasha256signaturevalue,-key)

# Access Tokenの発行
## Request URL
Method: POST
URL: `https://auth.worksmobile.com/oauth2/v2.0/token`

## Request Headers
`Content-Type : application/x-www-form-urlencoded`

## Request Body
以下の情報を`payload`に含める。
```javascript
{
    'assertion': jwt, // 上記で生成したJWT
    'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer', // 固定値。
    'client_id': config.CLIENT_ID, // Client ID
    'client_secret': config.CLIENT_SECRET, // Client Secret
    'scope': config.SCOPE // OAuth Scopes
}
```

## Request
まとめるとこんな感じ。レスポンスはパースしている。
```javascript
const endpoint = 'https://auth.worksmobile.com/oauth2/v2.0/token';
const options = {
  method: 'post',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  payload: {
    'assertion': jwt, // 上記で生成したJWT
    'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer', // 固定値。
    'client_id': config.CLIENT_ID, // Client ID
    'client_secret': config.CLIENT_SECRET, // Client Secret
    'scope': config.SCOPE // OAuth Scopes
  }
}
const response = JSON.parse(UrlFetchApp.fetch(endpoint, options);
```

## Response Sample
`access_token`が今回の目的であるAccess Token。つまり、上記のコードの`response.access_token`で取得できる。
```例.js
{
    'access_token':'jp1AAABFNKyxc7xsVRQVKrTNFchiiMkQrfJMDM6whobYxfbO4fsF23mvuxRvSuMY57DG4uPI/NI4eNMSt8sroqpqFhe3HemLI3OvCar5FFfOQdqUBgqFA/MaHZVXHqsNJgoX7KaGwDTum+zhEyfwjGSrrJZfSoRpTHHrwny4F4UDEA1Lep3dVUUUKAIQHcq0TwCjiWkMnJAXMEFFfbdVzH3FCv+kpb2OH1NbYzL376fXLh3vMUlyRBXPTf3Lv0bK5NsvjR3BNMR3GSvVzjM59lR5ctBK8PvtTdmaHbVGXzJBHv+S3mp1UuD0szSuxCsWUrdCS7/PiWbQwM4++k+WM/bta5EB9v9s9YQGlyklE3fqhnYLGx/9jWanFgrvptCambOW8lv5A==',
    'refresh_token':'jp1AAAAVq8kTeVPKkD11iLMP1mTqzYOd2T/r2x6QoBM2P3D8X6FfDi9wG5Hepsmh/LVpo3n3d/jcP/rnhtEw1VOpU4MJnxHVzu1x5VhKRmG/o63HERu2bnMtFHQVsjhljcf5fpm+Q==',
    'scope': 'bot',
    'token_type': 'Bearer',
    'expires_in': 86400
}
```

## config
`config`の中身はこんな感じ。`PRIVATE_KEY`はテンプレートリテラルを使って改行を表現しているので、フォーマットが崩れると、JWT生成時の電子署名で躓く。

```config.js
const config = {
  SCOPE: 'bot,bot.read',
  CLIENT_ID: 'ZbsOq6zjt0IhtZZnrc',
  CLIENT_SECRET: '123456',
  SERVICE_ACCOUNT: '1wagx.serviceaccount@example.com',
  PRIVATE_KEY: `-----BEGIN PRIVATE KEY-----
  xxxx
  xxxx
  xxxx
-----END PRIVATE KEY-----`
}
```


# APIの利用
## Request URL
使いたいAPIを[ドキュメント](https://developers.worksmobile.com/jp/reference/introduction?lang=ja)で探す。
例として「メッセージの送信 - トークルーム指定」を使ってみる。
パスパラメーターとして、`botId`と`channelId`が必要。

Method: POST
ENDPOINT: `https://www.worksapis.com/v1.0/`
PATH: `/bots/{botId}/channels/{channelId}/messages`
URL: `https://www.worksapis.com/v1.0/bots/{botId}/channels/{channelId}/messages`

## Request Headers
上記で取得したAccess Tokenを使って認証する。
`Authorization: Bearer {Access Token}`
`Content-Type: application/json`

## Request Body
今回はテキストメッセージだけを送信する。ほかにもクイック返信とかボタンとかいろいろできるみたい。詳しくは[トーク共通プロパティ](https://developers.worksmobile.com/jp/reference/bot-send-content?lang=ja)。
```例.js
{
  'content': {
    'type': 'text',
    'text': 'hello'
  }
}
```

## Request
それらをまとめるとこんな感じ。`accessToken`には上記で取得したAccess Tokenを格納しておく。`payload`はJSON文字列に変換しないとエラーとなる。
```javascript
const url = 'https://www.worksapis.com/v1.0/bots/12345/channels/67890/messages'
const options = {
  method: 'post',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json'
  },
  payload: JSON.stringify({
    'content': {
      'type': 'text',
      'text': 'hello'
    }
  })
}

const response = UrlFetchApp.fetch(url, options);
```
うまくいった。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2528323/384fea8b-f382-0afe-4361-2a26fa8c9310.png)

# Access Tokenの再発行
Access Tokenは一度取得すれは24時間は同じものが利用できるが、今回のスクリプトでは考慮しておらず、都度発行している。Access Tokenと取得した時間を保存しておき、必要に応じてAccess Tokenを取得するようにすればリクエスト回数を減らすことができるほか、パフォーマンスの向上も望める。その際はRefresh Tokenも併せて活用するとよき。


# 全体像
```javascript
const getAccessToken = (config) => {
  const time = Date.now();
  const header = Utilities.base64Encode(JSON.stringify({ 'alg': 'RS256', 'typ': 'JWT' }));
  const claimSet = Utilities.base64Encode(JSON.stringify({
    'iss': config.CLIENT_ID,
    'sub': config.SERVICE_ACCOUNT,
    'iat': Math.floor(time / 1000),
    'exp': Math.floor(time / 1000 + 3600)
  }));
  const signature = Utilities.base64Encode(Utilities.computeRsaSha256Signature(`${header}.${claimSet}`, config.PRIVATE_KEY));
  const jwt = `${header}.${claimSet}.${signature}`;

  const endpoint = 'https://auth.worksmobile.com/oauth2/v2.0/token';
  const options = {
    method: 'post',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    payload: {
      'assertion': jwt,
      'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer',
      'client_id': config.CLIENT_ID,
      'client_secret': config.CLIENT_SECRET,
      'scope': config.SCOPE
    }
  }
  return JSON.parse(UrlFetchApp.fetch(endpoint, options));
}

const line = () => {
  const config = {
    SCOPE: 'bot,bot.read',
    CLIENT_ID: 'ZbsOq6zjt0IhtZZnrc',
    CLIENT_SECRET: '123456',
    SERVICE_ACCOUNT: '1wagx.serviceaccount@example.com',
    PRIVATE_KEY: `-----BEGIN PRIVATE KEY-----
    xxxx
    xxxx
    xxxx
-----END PRIVATE KEY-----`
  }

  const accessToken = getAccessToken(config).access_token;
  const url = 'https://www.worksapis.com/v1.0/bots/12345/channels/12345/messages'
  const options = {
    method: 'post',
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Content-Type': 'application/json'
    },
    payload: JSON.stringify({
      'content': {
        'type': 'text',
        'text': 'hello'
      }
    })
  }

  const response = UrlFetchApp.fetch(url, options);
}

```



# Callback URLについて
LINE WORKSのトークBotには、受信したメッセージやイベント情報を、受信サーバーへ転送してくれる機能がある。これにより、GASをウェブアプリとして公開して発行したURLを、コールバック先に指定してあげれば、GASでメッセージの受信が可能となる。
しかしながら、現状GASにはリクエストヘッダーにアクセスする方法がないため、LINE WORKSの署名が確認できず、リクエスト元の検証ができない。つまり、LINE WORKSからではないリクエストに対しても応答してしまう危険性がある。
よって、受信も含めたトークBotを作りたいならGASはやめた方がいい。どうしてもというのであれば、Google Cloud Functionをコールバック先として、ヘッダー情報を本文に含めてApps Script APIを使ってGASに転送する方法があるみたい。

https://issuetracker.google.com/issues/67764685

# おわりに
今回初めてLINE WORKSを触ってみたけど、Client IDとかBot IDとかChannel IDとか必要な情報が点在していて、APIを使う前の収集がそれなりにめんどくさかった。やっぱSlackが最高。


[^1]: https://qiita.com/kunihiros
[^2]: https://developers.worksmobile.com/jp/reference/authorization-sa?lang=ja