kintoneプラグインにAPIトークンを秘匿して、JSカスタマイズから利用する

## はじめに

kintoneには「外部APIの実行に必要な情報をプラグインへ保存する」という機能があります。これを使うとAPIトークンなどセンシティブな情報を秘匿できるので、`kintone.plugin.app.proxy()`で比較的安全[^1]に外部APIを叩くことができます。

https://cybozu.dev/ja/kintone/docs/js-api/plugins/set-config-for-proxy/

[^1]: あくまで「比較的」です。`kintone.plugin.app.proxy()`を実行するコードはデバッグコンソールから確認できるので、そのコードをそのまんま叩けば、APIトークンを使ったリクエストを好きなタイミングで投げることはできちゃうので。APIトークンそのものは見えませんが過信は禁物です。

しかしこれ、何故プラグイン限定なのでしょう。JSカスタマイズ内で`kintone.proxy()`を使うときだって、APIトークンは秘匿したいじゃないですか。でも出来ないんですよ！[^2]

[^2]: プラグインには「アプリ管理者しか閲覧できない設定画面」があるのでこういう芸当が可能なのは分かりますよ。でもサイボウズさんが本気出せば、kintone標準機能で「`kintone.proxy()`で使える環境変数」みたいな設定画面とか作れると思うんですよねー。出してほしいなぁ〜。

噂では、この機能を使いたいがために「1アプリに適用する専用のプラグイン」を毎度作っているエンジニアもいるんだとか… しかし毎度毎度プラグインを新しく作って、設定画面のUIをゴリゴリ書くって無駄じゃないですか。こういう本質的でない苦労は極力避けるべきです。

で、どうするのか？僕が編み出した方法がこちらになります！
* **「APIトークンを秘匿するためだけの汎用プラグイン」を作る**
* **JSカスタマイズから、プラグイン内に秘匿されたAPIトークンを使ってリクエストを投げる**

実はプラグイン内に秘匿したAPIトークンって、プラグイン外からも使えちゃうんですよ。知ってました？？
というわけで「kintone Proxy Config 設定プラグイン」を作りました！

https://github.com/goqoo-on-kintone/kintone-proxy-config-setting-plugin

このプラグインを使うJSカスタマイズのサンプルがこちら。

https://github.com/goqoo-on-kintone/kintone-proxy-config-setting-plugin-demo

[Goqoo on kintoneファミリー](https://github.com/goqoo-on-kintone)に初めてプラグインが加わりました。[^3]

[^3]: このプラグイン自身もGoqooを使って作りました。プラグイン名は一般ユーザーの目にも触れるので、GoqooやらGinueやらふざけた名前は控えて、普通の名前にしましたｗ

では、具体的な使い方の解説行ってみましょう！

## プラグインの導入方法

[MITライセンス](https://github.com/goqoo-on-kintone/kintone-proxy-config-setting-plugin/blob/main/LICENSE)で公開しているので何してもらっても良いんですが、**Zip形式での配布は行いませんので、プラグインのパッケージングは各自で実施してください。**
エンジニア向けのプラグインなので、最低限それだけのリテラシーがある人に使ってもらいたいですし、**各社ごとに証明書を独自で作ることでプラグインIDが分かれるので、より安全になると考えたからです。**

業務利用する場合は、`git clone`してから自社のprivateリポジトリとして`git push`しちゃってください。

### GitHubからクローン、ライブラリインストール
Node.js v18とyarnが必要です。
```shell
git clone https://github.com/goqoo-on-kintone/kintone-proxy-config-setting-plugin.git
cd kintone-proxy-config-setting-plugin
yarn install
```
### 証明書を作成
ランダムな名前で生成されるので、手動で`private-production.ppk`に変名してください。
社内用のprivateリポジトリなら、証明書もGitコミットしちゃって良いと思います。

```shell
yarn create-ppk
mv xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.ppk private-production.ppk
```

### ビルド、kintoneへの読み込み
パッケージングとアップロードを順番に行います。
```shell
yarn package:production # Created dist/plugin-production.zip
yarn upload:production

? kintoneのベースURLを入力してください 
(https://example.cybozu.com): https://example.cybozu.com
? ログイン名を入力してください: username
? パスワードを入力してください: [hidden]
Open https://example.cybozu.com/login?saml=off
Trying to log in...
Navigate to https://example.cybozu.com/k/admin/system/plugin/
Trying to upload dist/plugin-production.zip
dist/plugin-production.zip をアップロードしました!
✨  Done in 55.00s.
```

これで「kintoneシステム管理」画面に「Proxy Config 設定プラグイン」が現れればOK。

![CleanShot 2023-12-17 at 01.15.38@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/16c2bdab-11ad-3d58-0ebd-1ba709314dda.png)

もちろん`plugin-production.zip`をkintoneの画面からアップロードしても大丈夫です。

## サンプルその1: 自動採番

今回は前提として「kintone REST APIを、APIトークン認証で使う」というコンセプトで行きます。
外部APIを叩く機会はもちろん多いですが、意外と「kintoneのセッション認証ではアクセス権が足りないから、一部だけAPIトークン認証する」って機会もありますよね。

その代表格が「自動採番」機能。こんなアプリを前提に解説します。

![CleanShot 2023-12-17 at 01.27.05@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/906348d7-1fc2-9093-74e3-ec7f1019b3b3.png)

数値型の「連番」フィールドが1つだけという、男気あふれる仕様！
必須制約＆重複禁止制約も付けておきます。

重複禁止の連番を採番するときは「全レコードから最新の番号を取得して+1する」みたいにしますが、レコードアクセス権を使っている場合、最新レコードにアクセス権がない場合があるので、セッション認証では正しい連番が保証できません。

そこで、採番時だけはAPIトークン認証で全レコードを走査するわけです。

![CleanShot 2023-12-17 at 01.31.29@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/ec695fdf-83af-5692-6325-33aa0dd2e0a5.png)

ここで、ついJSカスタマイズ内にAPIトークンを書いてしまいたくなりますが、これは危ない。

https://github.com/goqoo-on-kintone/kintone-proxy-config-setting-plugin-demo/blob/main/src-example1/auto-number-danger.js
```js
{
  // Proxy経由で任意のAPIリクエストをする関数
  const fetchViaProxy = async ({ url, method, headers: reqHeaders = {}, body: reqBody = {} }) => {
    const [resBody, resStatus, resHeaders] = await kintone.proxy(url, method, reqHeaders, reqBody)
    if (resStatus !== 200) {
      console.error(resBody, resHeaders)
      throw new Error(resBody)
    }
    return JSON.parse(resBody)
  }

  kintone.events.on(['app.record.create.show', 'app.record.edit.show', 'app.record.index.edit.show'], async (event) => {
    // 連番フィールドは自動採番なので編集不可にする
    event.record.連番.disabled = true
    return event
  })

  kintone.events.on('app.record.create.submit', async (event) => {
    try {
      // 連番フィールドの最新値を取得（自分にレコードアクセス権がないレコードもAPIトークンで取得する）
      const appId = kintone.app.getId()
      const query = 'order by 連番 desc limit 1'
      const fields = `fields[0]=連番`
      const params = `?app=${appId}&query=${encodeURIComponent(query)}&${fields}`
      const record = await fetchViaProxy({
        url: 'https://example.cybozu.com/k/v1/records.json' + params,
        method: 'GET',
        // ❗❗❗❗❗ここに直接APIトークンを書いてしまうのはセキュリティ的に危険↓❗❗❗❗❗
        headers: { 'X-Cybozu-API-Token': 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' },
      }).then((res) => res.records[0])

      // 採番してレコードに反映
      const latestNumber = Number(record?.連番.value ?? 0)
      event.record.連番.value = latestNumber + 1
      return event
    } catch (e) {
      console.error(e)
      alert(`採番に失敗しました。`)
      return false
    }
  })
}
```

そこで、「Proxy Config 設定プラグイン」を活用します。
プラグインの設定画面を開くと、デフォルトではこうなっています。

![CleanShot 2023-12-17 at 01.38.40@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/d14672e7-1234-d9b0-69a1-a72c25ea9133.png)

URLとMethodの組み合わせをキーとして、いくつでも設定を増やせます。
HeadersとDataは生のJSONを自由に書いていく。エンジニアにはこの方が使いやすいよね！
今回は、`records.json`の`GET`なので、こんな感じ。

![CleanShot 2023-12-17 at 03.34.25@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/0fce2db7-ad70-eacf-dc19-76e9ead1d79b.png)

Headersには余計なものがあってもエラーにはならないので、`memo`みたいな独自キーに用途をメモしておくと分かりやすいと思います。

これをJSカスタマイズから使う場合は、こんな感じ。

https://github.com/goqoo-on-kintone/kintone-proxy-config-setting-plugin-demo/blob/main/src-example1/auto-number.js
```js
{
  // PluginProxy経由で任意のAPIリクエストをする関数
  const fetchViaPluginProxy = async ({ pluginId, url, method, headers: reqHeaders = {}, body: reqBody = {} }) => {
    const [resBody, resStatus, resHeaders] = await kintone.plugin.app.proxy(pluginId, url, method, reqHeaders, reqBody)
    if (resStatus !== 200) {
      console.error(resBody, resHeaders)
      throw new Error(resBody)
    }
    return JSON.parse(resBody)
  }

  kintone.events.on(['app.record.create.show', 'app.record.edit.show', 'app.record.index.edit.show'], async (event) => {
    // 連番フィールドは自動採番なので編集不可にする
    event.record.連番.disabled = true
    return event
  })

  kintone.events.on('app.record.create.submit', async (event) => {
    try {
      // 連番フィールドの最新値を取得（自分にレコードアクセス権がないレコードもAPIトークンで取得する）
      const appId = kintone.app.getId()
      const query = 'order by 連番 desc limit 1'
      const fields = `fields[0]=連番`
      const params = `?app=${appId}&query=${encodeURIComponent(query)}&${fields}`
      const record = await fetchViaPluginProxy({
        pluginId: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
        url: 'https://example.cybozu.com/k/v1/records.json' + params,
        method: 'GET',
      }).then((res) => res.records[0])

      // 採番してレコードに反映
      const latestNumber = Number(record?.連番.value ?? 0)
      event.record.連番.value = latestNumber + 1
      return event
    } catch (e) {
      console.error(e)
      alert(`採番に失敗しました。`)
      return false
    }
  })
}
```

`kintone.plugin.app.proxy()`の第一引数に渡すプラグインIDは、プラグイン内部では`kintone.$PLUGIN_ID`を使って自動取得できますが、JSコードや他のプラグインではIDを直書きする必要があります。でもそこだけ気をつければ、プラグイン外部からも`kintone.plugin.app.proxy()`が普通に使えます。

これによって、JSカスタマイズで書きたいロジックはJS側で書き、機密情報の秘匿だけを汎用プラグインに任せることができるようになりました。
どうだい？便利だろぉ〜〜〜？？？

## サンプルその2: 別アプリの参照・更新・コメント書き込み

「その1」は単体アプリ内でのレコードアクセス権対応でしたが、次は「アクセス権がない別アプリ」を想定します。
ちょうどkintoneアプリストアに用意されている「営業支援パック」が使いやすかったので、これをそのまま使ってJSカスタマイズを入れてみようと思います。
![CleanShot 2023-12-17 at 01.59.55@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/818b85ae-ec0d-1f5a-8d28-629593fdc09f.png)

カスタマイズの仕様はこんな感じ

* 案件管理アプリに適用
* ルックアップコピー対象の「部署名」「担当者名」を手動編集可能にする
* レコードの新規追加・更新が成功したときに、「部署名」「担当者名」が更新されていたら以下の処理を行う
    * 顧客管理（親アプリ）レコードの「部署名」「担当者名」を更新
    * 活動履歴（子アプリ）レコードコメントに、顧客管理レコードを更新したことを投稿
* ユーザーが親アプリ・子アプリにアクセス権がなくても動くようにAPIトークン認証する

![CleanShot 2023-12-17 at 02.53.55.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/61f790d6-0ccf-748e-9888-948e07370221.gif)


今度は超ガチで、Goqooフレームワーク上でTypeScriptバリバリ使って書きました。

https://github.com/goqoo-on-kintone/kintone-proxy-config-setting-plugin-demo/blob/main/src/apps/project/update-customer.ts

```ts
import { context } from 'context'
import type { DetailEvent } from 'types'

type CustomerRecord = kintone.types.SavedCustomerFields
type ProjectRecord = kintone.types.SavedProjectFields
type SalesActivityRecord = kintone.types.SavedSalesActivityFields
type KintoneProxyResponse = [string, number, Record<string, string>]
type KintoneApiResponse<T> = { records: T[] }

// PluginProxy経由で任意のAPIリクエストをする関数
const fetchViaPluginProxy = async <T>({
  pluginId,
  url,
  method,
  headers: reqHeaders = {},
  body: reqBody = {},
}: {
  pluginId: string
  url: string
  method: string
  headers?: Record<string, string>
  body?: any
}): Promise<T> => {
  const [resBody, resStatus, resHeaders] = (await kintone.plugin.app.proxy(
    pluginId,
    url,
    method,
    reqHeaders,
    reqBody
  )) as KintoneProxyResponse
  if (resStatus !== 200) {
    console.error(resBody, resHeaders)
    throw new Error(resBody)
  }
  return JSON.parse(resBody)
}

kintone.events.on(['app.record.create.show', 'app.record.edit.show'], (event: DetailEvent<ProjectRecord>) => {
  const { record } = event
  // ルックアップコピー先フィールドを編集可能にしておく
  record.部署名.disabled = record.担当者名.disabled = false
  return event
})

kintone.events.on(['app.record.create.submit.success', 'app.record.edit.submit.success'], async (event: DetailEvent<ProjectRecord>) => {
  const appId = {
    customer: kintone.app.getLookupTargetAppId('顧客名')!,
    salesActivity: kintone.app.getRelatedRecordsTargetAppId('案件に紐付く活動履歴')!,
  }
  const {
    顧客管理レコード番号_関連レコード紐付け用: { value: customerRecordId },
    部署名: { value: deptName },
    担当者名: { value: contactName },
  } = event.record

  let customerRecord: CustomerRecord
  let salesActivityRecords: SalesActivityRecord[]

  try {
    // 顧客管理レコードを取得
    const customerQuery = `$id="${customerRecordId}"`
    const customerFields = ['$id', '部署名', '担当者名'].map((fieldCode, i) => `fields[${i}]=${fieldCode}`).join('&')
    const customerParams = `?app=${appId.customer}&query=${encodeURIComponent(customerQuery)}&${customerFields}`
    const customerPromise = fetchViaPluginProxy<KintoneApiResponse<CustomerRecord>>({
      pluginId: context.externalApi.proxyConfigPluginId,
      url: context.externalApi.kintone.recordsGet.url + customerParams,
      method: context.externalApi.kintone.recordsGet.method,
    }).then((res) => res.records[0])

    // 活動履歴レコードを取得
    const salesActivityQuery = `顧客管理レコード番号_関連レコード一覧紐付け用="${customerRecordId}"`
    const salesActivityFields = ['$id', '対応者'].map((fieldCode, i) => `fields[${i}]=${fieldCode}`).join('&')
    const salesActivityParams = `?app=${appId.salesActivity}&query=${encodeURIComponent(salesActivityQuery)}&${salesActivityFields}`
    const salesActivityPromise = fetchViaPluginProxy<KintoneApiResponse<SalesActivityRecord>>({
      pluginId: context.externalApi.proxyConfigPluginId,
      url: context.externalApi.kintone.recordsGet.url + salesActivityParams,
      method: context.externalApi.kintone.recordsGet.method,
    }).then((res) => res.records)

    const awaited = await Promise.all([customerPromise, salesActivityPromise])
    customerRecord = awaited[0]
    salesActivityRecords = awaited[1]
  } catch (e) {
    console.error(e)
    alert(`部署名または担当者名の変更チェックに失敗しました。
※このレコード（案件管理）は問題なく更新されています。 `)
    return
  }

  if (customerRecord.部署名.value === deptName && customerRecord.担当者名.value === contactName) {
    return
  }

  // 部署名・担当者名に変更があれば、顧客管理レコードを更新する
  try {
    await fetchViaPluginProxy({
      pluginId: context.externalApi.proxyConfigPluginId,
      url: context.externalApi.kintone.recordPut.url,
      method: context.externalApi.kintone.recordPut.method,
      headers: { 'Content-Type': 'application/json' },
      body: {
        app: appId.customer,
        id: customerRecordId,
        record: {
          部署名: { value: deptName },
          担当者名: { value: contactName },
        },
      },
    })
    alert('部署名または担当者名が変更されたので、顧客管理レコードも同時に更新しました。')
  } catch (e) {
    console.error(e)
    alert(`部署名または担当者名が変更されましたが、顧客管理レコードの同時更新が失敗しました。
※このレコード（案件管理）は問題なく更新されています。 `)
    return
  }

  // さらに活動履歴アプリにもコメントを投稿する（複数レコードあれば全て）
  try {
    for (const salesActivityRecord of salesActivityRecords) {
      await fetchViaPluginProxy({
        pluginId: context.externalApi.proxyConfigPluginId,
        url: context.externalApi.kintone.commentPost.url,
        method: context.externalApi.kintone.commentPost.method,
        headers: { 'Content-Type': 'application/json' },
        body: {
          app: appId.salesActivity,
          record: salesActivityRecord.$id.value,
          comment: {
            text: `案件管理レコードで部署名・担当者名が更新され、顧客管理レコードにも反映されました。
・部署名: ${deptName}
・担当者名: ${contactName}`,
            mentions: salesActivityRecord.対応者.value.map((user) => ({ code: user.code, type: 'USER' })),
          },
        },
      })
    }
    alert('活動履歴アプリにレコードコメントを投稿しました。')
  } catch (e) {
    console.error(e)
    alert('活動履歴アプリへのコメント投稿に失敗しました。')
    return
  }
})
```

細かい解説すると長くなりすぎるので、「プラグインの設定」に関連する部分だけ解説します。このカスタマイズ内では、APIリクエストが全部で4回走ります。

|アプリ|操作|メソッド, コマンド|APIトークンの権限|
|---|---|---|---|
|顧客管理|レコード一括取得|GET, records.json|閲覧権限|
|活動履歴|レコード一括取得|GET, records.json|閲覧権限|
|顧客管理|レコード一括更新|PUT, records.json|更新権限|
|活動履歴|コメント投稿|POST, record/comment.json|閲覧権限[^4]|

[^4]: コメント投稿ってAPIトークンでは閲覧権限なのですね！見れる人はコメント書けるってことか。そういえばGoogle Docsは「閲覧」「閲覧(コメント可)」「編集」に分かれてたりしますね。

この場合、プラグインの設定はこんな風になります。

![CleanShot 2023-12-17 at 03.04.03@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/0e4ceb86-fcb7-cf0a-dafe-16004599e792.png)

* 顧客管理の取得/更新はどちらも`records.json`だが[^5]、メソッドが`GET` `POST`で異なるので、設定行は分ける。
* 顧客管理の取得/活動履歴の取得はどちらも`records.json`の`GET`なので[^6]、アプリは違うが設定行は分けられない。この場合はAPIトークンをカンマで連結して、両アプリ対応のトークンにすることでうまくいく。

[^5]: 顧客管理の取得は、レコード番号をキーに1件だけなので`record.json`で十分ですが、解説の都合上あえて`records.json`を使いました。
[^6]: 顧客管理の更新は、1件だけなので`record.json`で十分ですが以下同文

以上、解説終わり！
（ほかに気になる点あれば、記事へのコメントやGitHub Issueいただければ何でも答えますよ〜）

## おわりに

懺悔しますと、僕も今まではけっこうAPIトークンをJS内に書いてリリースしちゃうことがありました。Gitにはコミットしないように`.env`に書いてwebpackでバンドルするようにしていましたが、バンドルした瞬間に`.env`の中身がJSに取り込まれてしまうので、kintoneユーザーから見るとあまり意味がないんですよね。。。

もちろんkintoneアプリはログインせずに使うことができないので、一般公開のWebサービスほど神経質になる必要はないんですが、やはりセキュリティを担保するには、フロントエンドのJSにAPIトークンを混ぜるのはNGです。

ということで、4月に突如思い立ってガッと作ってひっそりとpublic公開し、自分の仕事ではそれなりに活用していた本プラグインでしたが、年末にやっと解説記事を書くことができました。アドベントカレンダーはとてもよい機会ですね〜。

この記事ではkintone REST APIを叩く例ばかり出しましたが、もちろん外部APIを叩くときも全く問題なく使えます。たとえば認証情報だけでなく、リクエストボディで送りたいデータをプラグイン内に秘匿しておくとか、使い方の応用は色々効くと思います。

画面作りが好きじゃない方には特に嬉しいのではないかと思うので、ぜひ活用してみてくださいね！