6
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-06-10

はじめに

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

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

前提条件

  • 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エンコードをする。ここは固定値。

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から情報を取得するようにしたいので、以下のようにした。

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を取得する前提。

const signature = Utilities.base64Encode(Utilities.computeRsaSha256Signature(`${headers}.${claimSet}`, config.PRIVATE_KEY));

結合

const jwt = `${header}.${claimSet}.${signature}`;

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に含める。

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

まとめるとこんな感じ。レスポンスはパースしている。

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をドキュメントで探す。
例として「メッセージの送信 - トークルーム指定」を使ってみる。
パスパラメーターとして、botIdchannelIdが必要。

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

今回はテキストメッセージだけを送信する。ほかにもクイック返信とかボタンとかいろいろできるみたい。詳しくはトーク共通プロパティ

例.js
{
  'content': {
    'type': 'text',
    'text': 'hello'
  }
}

Request

それらをまとめるとこんな感じ。accessTokenには上記で取得したAccess Tokenを格納しておく。payloadはJSON文字列に変換しないとエラーとなる。

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

Access Tokenの再発行

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

全体像

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に転送する方法があるみたい。

おわりに

今回初めて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

6
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?