カミナシ エンジニアブログ

株式会社カミナシのエンジニアが色々書くブログです

【脱PAT】local環境でのプライベートGoモジュールの利用にGitHub Appのデバイスフローが使える

突然ですが、あなたの.netrcや環境変数に、ghp_...から始まる”あの文字列“、そっと忍ばせていませんか?

そう、GitHubのPersonal Access Token (PAT) です。

「自分しか使わないから」「有効期限なしが一番ラクだから」しかしその”魔法の文字列”は、開発効率を上げる便利な鍵であると同時に、ひとたび漏洩すれば全てを危険に晒す諸刃の剣。

果たして、この便利で危険な”魔法”を、私たちは本当に封印できるのでしょうか……?

local環境でセキュアにプライベートGoモジュールをダウンロードしたい

こんにちは。カミナシ認証認可ユニットで共通ID基盤を開発しているminaです。

私たちのチームでも、”便利で危険な魔法”PATに頼らない方法はないか、頭を悩ませた経験がありました。

というのも、私たちは共通ID基盤へのAPIリクエストとサービスのセッション管理を行うことができるSDKを、Goのモジュールとして社内の各開発チームに配布しています。

このGoモジュールはGitHubのプライベートリポジトリで管理しているため、各サービスのGoアプリケーションで利用するときは、ビルドする際にリポジトリのアクセス権限があることを示す、PATやSSHキーなどのGitHubクレデンシャルが必要になるのです。

私たちが最も苦心したのは、開発者のlocalコンテナ環境でプライベートなGoモジュールをダウンロードする際に、セキュアかつ運用が簡単なGitHubクレデンシャルはないか、という点でした。

要件を整理してみると

改めて開発者のlocalコンテナ環境においてプライベートなGoモジュールをダウンロードするための要件を整理してみました。

まず機能要件は以下の通り。

  • コンテナがプライベートなGoモジュールをダウンロードするときに、該当のGitHubリポジトリの読み取り権限のあるGitHubクレデンシャルが利用可能であること

そして、非機能要件として、セキュリティに考慮する必要があります。

事前調査で、Git Credential Managerなどを用いて開発者のマシン内の安全な場所にクレデンシャルを保管し、それを必要時にコンテナ環境に渡す方法は実装が複雑になりすぎることがわかったため、限定的なクレデンシャルを用いることを要件としました。

  • GitHubクレデンシャルの有効期間を短くすること
  • GitHubクレデンシャルが持つ権限を必要最小限にすること

また、開発者体験もよいものにしたいです。

  • 必要最小限のユーザー操作でGitHubクレデンシャル取得ワークフローが完了すること

セキュリティ上有効期限の長いクレデンシャルは使いたくない一方で、クレデンシャル取得のための操作は最小限にしたいという相反する状況です。

なぜPATを避けたいのか

よく見られる解決策のひとつが冒頭でも述べたPATです。実際に、GoモジュールのリファレンスでもPATが紹介されています。

https://go.dev/ref/mod#private-module-repo-auth

PATは各開発者がブラウザでGitHubにログインしてトークンを発行したあと、例えば~/.netrcにそのトークンをコピーしてコンテナ環境に渡せばいいだけなので構築が簡単です。

しかし、私たちは下記の点で上記で挙げた要件を満たせていないため、PATの利用は好ましくないと考えました。

  • セキュアな運用にかかるコストが高い

    • PATに含める権限や有効期限の選択が各開発者任せになるため、必要以上に権限が強かったり有効期限が長いPATが使われる可能性がある
  • 開発者体験がよくない

    • PATの定期的なローテーションを手動で行うのは煩雑

GitHub Appのユーザーアクセストークンが使える!

そこで注目したのがGitHub Appです。GitHub AppはOAuth 2.0クライアントとして動作し、ユーザーの認可を受けて、ユーザーの代理としてGitHubにAPIコールができます。認可の証としてアクセストークンを使用します。

https://docs.github.com/ja/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-with-a-github-app-on-behalf-of-a-user

このアクセストークンは「ユーザーアクセストークン」と呼ばれています。有効期限や権限は次の通りです。

  • 有効期限:8時間
  • 権限:ユーザーとGitHub Appsの両方が持っているアクセス許可のみ

プライベートGoモジュールのあるリポジトリの読み取り権限を持つユーザーアクセストークンを取得してgitに渡せば、該当モジュールのダウンロードが可能になります。有効期限が短く、トークンの持つ権限もコントロールできるので、今回の目的にぴったりです。

コンテナ環境におけるOAuthにデバイスフローが有効

次にこのユーザーアクセストークンをどのように発行するかを考えます。有効期限が短いので、コンテナ上のサーバーが参照するプライベートGoモジュールのバージョンを更新してビルドするたびに、新しいトークンが必要です。

これは難しい問題でしたが、GitHub Appのデバイスフローで解決できました。

https://docs.github.com/ja/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app#using-the-device-flow-to-generate-a-user-access-token

デバイスフローとは、「OAuth 2.0 Device Authorization Grant (RFC8628)」で仕様が定められている認可方式です。

https://datatracker.ietf.org/doc/html/rfc8628

OAuthで一般的なのはWebアプリケーションフローと呼ばれるフローで、これは認可を受けたいアプリケーションとユーザーの認証・認可が同じ環境で行われます。

一方デバイスフローは、例えば「テレビのアプリケーションを利用開始するために、テレビに表示されたQRコードをスマホで読み取ってスマホのブラウザで認証・認可する」というように、アプリケーションとユーザー認証・認可が異なる端末で行われることを想定した仕様です。

今回の例で言えば、コンテナ上のGitHub AppがGitHubを利用するために、ホストマシンでユーザーが認証・認可するということになります。これにより、コンテナとの対話的なプロンプトなしでOAuthを完結させられるようになりました。

デバイスフローは、異なる環境の整合性をとるために2種類のコードが登場したり、ポーリングタイムが仕様で定められていたりしてなかなか面白いのですが、深掘りすると記事が終わらなくなってしまうので次へ進みます。

注・厳密には、GitHub AppのデバイスフローはRFC8628と異なる部分もあります。またGitHub Appのデバイスフローはパブリックプレビューなので、変更される可能性があります。

実装してみる

GitHub Appの用意

まずGitHub Appを作成します。付与する権限は、Repository Permissions > Contents で read-only を選択します。

作成したGitHub Appを、利用したいGoモジュールのあるリポジトリにインストールします。これでこのGitHub Appが該当リポジトリを読み取れるようになります。

シェルスクリプト

次に、デバイスフローを実行するスクリプトを用意します。説明を簡単にするため、エラーハンドリングを省略しているところがあります。

#!/bin/bash

# --- JSONパース用のヘルパー関数 ---
# $1: JSON文字列
# $2: 抽出したいキー名
# $3: 型 ("string" または "number")。省略した場合は "string"
parse_json() {
  local json_string="$1"
  local key="$2"
  local type="${3:-string}"
  if [ "$type" = "number" ]; then
    echo "${json_string}" | sed -n "s/.*\"${key}\":\s*\([0-9]\{1,\}\).*/\1/p"
  else
    echo "${json_string}" | sed -n "s/.*\"${key}\":\"\([^\"]*\)\".*/\1/p"
  fi
}

# --- 設定 ---
CLIENT_ID="GitHub AppのクライアントID"

# --- メイン処理 ---
echo "GitHub Appのデバイスフローを利用してGitHubの認証情報を取得します。"

# ステップ1: デバイスコード及びユーザコードの要求
device_code_response=$(curl -s -X POST \
  -H "Accept: application/json" \
  https://github.com/login/device/code \
  -d "client_id=${CLIENT_ID}"
)

device_code=$(parse_json "${device_code_response}" "device_code" "string")
user_code=$(parse_json "${device_code_response}" "user_code" "string")
verification_uri=$(parse_json "${device_code_response}" "verification_uri" "string")
interval=$(parse_json "${device_code_response}" "interval" "number")
expires_in=$(parse_json "${device_code_response}" "expires_in" "number")

# ステップ2: ユーザコードの入力をユーザに促す
echo "============================================================"
echo "ブラウザで以下のURLを開き、コードを入力してください(15分間有効):"
echo "URL: ${verification_uri}"
echo "Code: ${user_code}"
echo "============================================================"
echo "ユーザーの同意を待っています。"
echo ""

# ステップ3: ユーザがデバイスを認可したかポーリング
while true; do
  # アクセストークンをリクエスト
  token_response=$(curl -s -X POST \
    -H "Accept: application/json" \
    https://github.com/login/oauth/access_token \
    -d "client_id=${CLIENT_ID}" \
    -d "device_code=${device_code}" \
    -d "grant_type=urn:ietf:params:oauth:grant-type:device_code")

  # レスポンスのパース
  access_token=$(parse_json "${token_response}" "access_token" "string") 
  error=$(parse_json "${token_response}" "error" "string")

  # 認証結果の判定
  if [ -n "$access_token" ] && [ "$access_token" != "null" ]; then
    echo "GitHub認証情報の取得に成功しました。"
    git config --global url."https://oauth2:${access_token}@github.com/".insteadOf "https://github.com/"
    exit 0
  fi

  case "$error" in
    "authorization_pending")
      # ユーザーがまだ認証していない -> ポーリング継続
      ;;
    "slow_down")
      # ポーリングが速すぎる -> 間隔を5秒増やして継続
      interval=$((interval + 5))
      ;;
    *)
    # その他のエラー、またはエラーがない(レスポンスが空など)の場合
    echo "予期せぬエラーが発生しました: ${error}"
    echo "レスポンス詳細: ${token_response}"
    exit 1
  esac

  sleep "$interval"
done

このシェルスクリプトを、プライベートGoモジュールをダウンロードしたいタイミングでコンテナ上で実行します。

私たちの場合は、利用したい環境でGoのホットリロードツールであるAirを利用していたため、Airのpre_cmdフックを活用し、サーバーのビルド前にgo mod downloadを試み、失敗した(=プライベートなGoモジュールのダウンロードを試みたがGitHubクレデンシャルがなかった)場合に上記のスクリプトを実行して.gitconfigを更新し、再度go mod downloadを実行するという処理にしました。

実行例

上記のスクリプトをコンテナで実行すると、コンテナログに下記が表示されます。

GitHub Appのデバイスフローを利用してGitHubの認証情報を取得します。
============================================================
ブラウザで以下のURLを開き、コードを入力してください(15分間有効):
URL: <https://github.com/login/device>
Code: A93F-12DC
============================================================
ユーザーの同意を待っています。

提示されたURLをブラウザで開くと、GitHubにログイン後、デバイスフローの開始画面に移動します。

ターミナルに表示されていた8桁のユーザーコードを入力します。

GitHub Appを認可します。

これでコンテナ上のGitHub Appが「ユーザーとGitHub Appの両方が持っているアクセス許可のみ」の権限をもつユーザーアクセストークンを取得できました。コンテナはこのトークンを使ってプライベートGoモジュールをダウンロードできるようになります。

検討したが採用しなかった方法

GitHub Appのインストールトークンを使う方法

GitHub Appを利用して取得できるトークンにはインストールトークンというものもあります。これはアプリ自身で認証して取得できるトークンです。

https://docs.github.com/ja/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation

こちらはユーザーの認証・認可が不要になるという大きなメリットがありますが、アプリ自身の認証のために、アプリと紐付けた秘密鍵を各開発者のマシンで保管する必要があります。秘密鍵の漏洩リスクや各開発者に紐付けたGitHub Appの管理の煩雑さがあるため、今回は採用しませんでした。

CIでは環境変数から安全に読み出しができるので、CI上でビルドするときはこちらの方法を採用しています。

GitHub CLIのデバイスフローを使う方法

GitHub CLIのgh auth loginコマンドでもデバイスフローが実行されます。GitHub Appを使う場合のように、わざわざデバイスフローを実行するスクリプトを準備する必要がないというメリットがあります。

しかし、GitHub CLIで取得できるトークンは、GitHub Appで取得できる、有効期限が短く権限を限定できるトークンと異なり、無期限かつユーザーの全権限を持っている強力なトークンです。自身の持つ強力なトークンをコピーして持ち出す習慣を開発者に身につけさせたくないため採用しませんでした。

おわりに

「コンテナごとにPATを埋め込む悪夢、その期限管理に怯える週末……」

私たちはその呪縛から開発者を解放するため、認証・認可の迷宮へと旅立ちました。そしてついにこの記事で紹介した「GitHub Appのデバイスフロー」という宝の地図を発見したのです。


認証・認可の仕様を深掘りしつつ、セキュリティと開発者の利便性の最適なバランスを探る作業はやりがいがありました。今回紹介した手法は実際に運用されており、開発者からは「PATよりも負担は増えておらず、むしろセキュリティが向上した」と好評でした!

もしあなたが、こうした認証・認可沼にどっぷり浸かり、私たちと次の「最適解」を追求したいと思うなら、以下のリンクからぜひご連絡ください。最高の仕事場で、あなたを待っています。