Closed10

Google Apss Script で GitHub Apps の Access Token を取得

hankei6kmhankei6km

GitHub App の作成

GitHub のウェブ UI からプロフィールアイコンのメニューから「Settings」「Developer Settings」「GitHub Apps」で「New GitHub App」をクリック。

入力する内容はだいたい冒頭のページの通り。

GitHub App nameはアプリの名前をつけてあげて下さい。
Homepage URLは適当でOKです。
WebhookはActiveのチェックを外してあげてください。
権限は必要なものをつけていきます。

「Repository permissions」に(mandatoryの「Metadata」以外では)「Workflows」にRead and writeを付けている(read only は無かった)。ここは「Webhooks 」の Read-only にするかも。

あとは Private key を Download(Generate)しておく。

hankei6kmhankei6km

workflow_dispatch でワークフローを開始する場合 Workflows ではなく Actions で Read and Weite が必要だった。

repository_dispatch は試していないので、そちらだとまた違う可能性もある。

hankei6kmhankei6km

GAS で Token の取得

GAS で jwt を扱う方法としては Zoom API 関連がヒットしやすい。
https://qiita.com/coticoticotty/items/56ff2a3324fe408b06ca

これらは署名アルゴリズムには HS256 を利用しているので少し要件にあわない。

Line の API では RS256 を利用しているらしく、それ関連の記事で利用しているコードを参考にする。
(具体的には Utilities.computeRsaSha256Signature() を利用)

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

しかし、Utilities.computeRsaSha256Signature() では GitHub App の Private Key(RSA 鍵)はそのままでは利用できない(Exception: Invalid argument: key エラーになる)。

computeRsaSha256Signature() の JsDoc のサンプルを見ると -----BEGIN PRIVATE KEY----- で始まるテキストを渡していたので変換する(参考のリンクがあったのだけど見失ってしまった。stackoverflow だったと思うので見つかったら追記 見つかったので追記)。

$ openssl pkcs8 -topk8 -inform pem -in privateKey.pem -outform pem -nocrypt -out newPrivate.pem

https://stackoverflow.com/questions/69133483/generate-json-web-token-rs256-to-access-docusign-using-google-apps-script/69133623#69133623

これでリクエスト用のトークンが生成できるので、GitHub の Tokne 取得 API 実行方法を調べる。以下がヒットする。

https://dev.classmethod.jp/articles/register-github-app-and-get-access-token/

ただし、少し情報が古いもよう。installation id の取得方法と API のアドレスが現状とは異なる。

API の PATH は /installations/<your-installation-id>/access_tokens ではなく /app/installations/<your-installation-id>/access_tokens となる(/app が付く)。

/app なしでやると Not Found になるので注意。

https://docs.github.com/ja/rest/reference/apps

hankei6kmhankei6km

とりあえずの実装

(クリックでソースコード表示)
/**
 * jwt の要素(?)用にエンコードする.
 * @param {string | any[]} s - ソース.
 */
function encodeItem_(s) {
  if (Array.isArray(s)) {
    return Utilities.base64Encode(s)
  }
  return Utilities.base64Encode(JSON.stringify(s))
}

/**
 * リクエスト用の jwt を生成.
 * @param {string} appId - App Id
 * @param {string} key - private key(RSA key は扱えない)
 */
function jwtToken_(appId, key) {
  const payload = {
    exp: Math.floor(Date.now() / 1000) + 60,  // JWT expiration time
    // ちょっとだけ時間を手前にしておくとアクセストークンの発行に失敗し辛いらしい。
    // https://qiita.com/icoxfog417/items/fe411b94b8e7ae229e3e#github-apps%E3%81%AE%E8%AA%8D%E8%A8%BC
    iat: Math.floor(Date.now() / 1000) - 10,       // Issued at time 
    iss: appId
  }
  const header = {
    'alg': 'RS256',
    'typ': 'JWT'
  };

  const src = `${encodeItem_(header)}.${encodeItem_(payload)}`
  const signed = Utilities.computeRsaSha256Signature(src, key)
  return `${src}.${encodeItem_(signed)}`
}

/**
 * Application Token を取得する.
 * @param {string} appId - App Id
 * @param {string} installationId - Installation Id
 * @param {string} key - private key(RSA key は扱えない)
 */
function ghToken(appId, installationId, key) {
  const t = jwtToken_(appId, key)
  try {
    const api_url = 'https://api.github.com'
    const path = `/app/installations/${installationId}/access_tokens`
    const res = UrlFetchApp.fetch(`${api_url}${path}`, {
      method: 'post',
      "headers": {
        Authorization: `Bearer ${t}`,
        Accept: "application/vnd.github.machine-man-preview+json"
      }, muteHttpExceptions: true
    })
    return res.getContentText()
  } catch (e) {
    console.error(e)
  }
}

function main() {
  const props = PropertiesService.getScriptProperties()
  const appId = props.getProperty('appId')
  const installationId = props.getProperty('installationId')
  const privateKey = props.getProperty('privateKey')

  const res = JSON.parse(ghToken(appId, installationId, privateKey))
  console.log(res)

  const now = Date.now()
  const e = new Date(res.expires_at)
  console.log((e.getTime() - now) / 1000 / 60)
}

スクリプトエディターで実行すると以下のように token を受け取る。

{ token: 'xxxxx',
  expires_at: '2022-03-31T13:08:52Z',
  permissions: { metadata: 'read', workflows: 'write' },
  repository_selection: 'selected' }

以下は expires_at から Date.now() を引いて分にした値。
ということで token の有効期限は 60 分のもよう(有効期限は 8 時間というのを見た気がするのだが)。

59.9884
hankei6kmhankei6km

ライブラリー

GAS のライブラリーにした。

https://github.com/hankei6km/gas-github-app-token

プロジェクトの OAuth スコープを付けたくなかったので、直接 Fetch するのではなく UrlFetchApp.fetch() 用の URL とオプションを生成するようにしてある。

hankei6kmhankei6km

clasp を使うなら jsonwebtoken を使えるかと思ったが、Node.JS のモジュールが必要だったので断念(鍵の変換を回避できるかと思ったが世の中甘くはなかった)。

(!) Missing global variable names
Use output.globals to specify browser global variable names corresponding to external modules
buffer (guessing 'require$$0')
stream (guessing 'require$$3')
util (guessing 'require$$5')
crypto (guessing 'require$$2')
hankei6kmhankei6km

Access Token を使う

当初の目的であったワークフローを開始してみる。token を使うこと自体はヘッダーに指定するだけなのでとくに問題はなかった(dispatch event を作成できる permission は workflows:write ではなく actions:write というところに少しハマる)。

  try {
    const api_url = 'https://api.github.com'
    const owner = 'hankei6km'
    const repo = 'test-slidev-export-to-gdrive'
    const workflowId = '21927562'
    const path = `/repos/${owner}/${repo}/actions/workflows/${workflowId}/dispatches`
    const token = JSON.parse(res).token
    const runRes = UrlFetchApp.fetch(`${api_url}${path}`, {
      method: 'post',
      "headers": {
        Authorization: `token ${token}`,
        Accept: "application/vnd.github.v3+json",
      },
      payload: '{"ref":"topic/workflow2"}',
      muteHttpExceptions: true
    })
    console.log(runRes.getContentText())
  } catch (e) {
    console.error(e)
  }

https://docs.github.com/ja/rest/reference/actions#create-a-workflow-dispatch-event

hankei6kmhankei6km

Personal Access Token と比べて

PAT の代わりに GitHub App を試した動機は「PAT は手間がかかる = 有効期限を短くしていると更新が手間」というところにあった。

その点でいうと必要なときに Access Token を取得できるので手間がかからなくなるのだが、「プライベートキーを外部に保存しているのって PAT の有効期限を短くしている状況に逆行してない?」と思わなくもない。

GitHub App の場合は「リポジトリ単位でインストール(許可)できる」「permission を後から変更できる」ので、その辺が PAT の代わりに利用するときの利点なのかなと(チームで使うとまた別の利点もある?)。

このスクラップは2022/04/03にクローズされました