はじめに
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エンコード
以下のようにキーと値のオブジェクトを生成する。
{
'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
で取得できる。
{
'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生成時の電子署名で躓く。
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をドキュメントで探す。
例として「メッセージの送信 - トークルーム指定」を使ってみる。
パスパラメーターとして、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
今回はテキストメッセージだけを送信する。ほかにもクイック返信とかボタンとかいろいろできるみたい。詳しくはトーク共通プロパティ。
{
'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);
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が最高。