Google Apss Script で GitHub Apps の Access Token を取得
Google Apps Script 側から GitHub Actions の Workflow を開始したくなった。
PAT を作成するのも手間なので他の方法を探してみたら GitHub Apps で Access Token を取得する方法が出てきた。
これを Google Apps Script から利用してみる。
記事にした。
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)しておく。
workflow_dispatch
でワークフローを開始する場合 Workflows ではなく Actions で Read and Weite が必要だった。
repository_dispatch
は試していないので、そちらだとまた違う可能性もある。
GAS で Token の取得
GAS で jwt を扱う方法としては Zoom API 関連がヒットしやすい。
これらは署名アルゴリズムには HS256
を利用しているので少し要件にあわない。
Line の API では RS256
を利用しているらしく、それ関連の記事で利用しているコードを参考にする。
(具体的には Utilities.computeRsaSha256Signature()
を利用)
しかし、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
これでリクエスト用のトークンが生成できるので、GitHub の Tokne 取得 API 実行方法を調べる。以下がヒットする。
ただし、少し情報が古いもよう。installation id の取得方法と API のアドレスが現状とは異なる。
API の PATH は /installations/<your-installation-id>/access_tokens
ではなく /app/installations/<your-installation-id>/access_tokens
となる(/app
が付く)。
/app
なしでやると Not Found
になるので注意。
とりあえずの実装
(クリックでソースコード表示)
/**
* 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
ライブラリー
GAS のライブラリーにした。
プロジェクトの OAuth スコープを付けたくなかったので、直接 Fetch するのではなく UrlFetchApp.fetch()
用の URL とオプションを生成するようにしてある。
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')
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)
}
Personal Access Token と比べて
PAT の代わりに GitHub App を試した動機は「PAT は手間がかかる = 有効期限を短くしていると更新が手間」というところにあった。
その点でいうと必要なときに Access Token を取得できるので手間がかからなくなるのだが、「プライベートキーを外部に保存しているのって PAT の有効期限を短くしている状況に逆行してない?」と思わなくもない。
GitHub App の場合は「リポジトリ単位でインストール(許可)できる」「permission を後から変更できる」ので、その辺が PAT の代わりに利用するときの利点なのかなと(チームで使うとまた別の利点もある?)。