GitHub Actions 内でのみ叩ける API を App Service 認証で実装する

  • watahani
  • 34 Minutes
  • December 1, 2022

本記事は Microsoft Azure Tech Advent Calendar 2022 1 日目の記事です。

ポケモンや仕事で忙しすぎて、昔実装した機能を今更まとめる記事になってしまいましたが、こんな感じのノリで OK だよという見本として、参加者の敷居を下げていきたいと思います。

ということで本題。GitHub Actions 使ってますか? GitHub のフリーアカウントでも、かなりの無料枠があるので有難く個人の Private Repository でも使わせてもらってます。図書館の借りた本の延長をするのがだるくて自動で延長する GitHub Actions 作ったりして cron で起動しても無料… 便利な時代だ。

Pull Request の時に外部 API を叩いて色々したい

GitHub Actions を触っていると、Pull Request で外部 API をキックしたいときがある。

ありませんか?

外部 API を呼び出す Secret を GitHub に埋め込めば良いのだが、今回の API は複数のリポジトリから呼び出される可能性があり、リポジトリ作るたびにシークレットを発行、管理をするのがだるいしセキュリティを考えると定期的なローテーションとかだるい。(お、なんか前回の記事と流れが同じだ…)

ということで勘のいい方なら気づいたかもしれないが、GitHub ID トークンで GitHub Actions 内で叩けるセキュアな API を実装しよう、という話。ちなみに、結果的にうまい実装にはならなかったのだが、こういう失敗談も誰かの参考になるかなと。

前提条件

前提条件はこんな感じ。

実装を考える

ちょうど実装しようとしていたころ GitHub Actions の ID トークン を使った認証が Azure AD のアプリ認証に使えるようになったことで、自分の中で話題になっていた。

GitHub の ID トークンは GitHub Actions の中で GitHub が署名した ID トークンを発行し、外部サービスの認証に利用できるもので、Subject などにリポジトリ名が入るのでそれを検証すれば、特定リポジトリからのアクセスのみを制限できそう。

GitHub Actions 側の実装

GitHubActions 側では適当に ID トークンを取得して、API を呼び出せばよさそう。ということで、以前検証した curl コマンドを使ったトークンをそのまま API 叩くように実装すればヨシ。

name: Call Protected API
on:
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - run: sleep 5 # there's still a race condition for now
      - name: Aquire ID Token
        run: |
          federated_token=`curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://githubactions" | jq -r '.value'`
          curl -H "Authorization: Bearer $federated_token" $FUNCTION_URL 
        env:
          FUNCTION_URL: ${{ secrets.FUNCTION_URL }}

API 側の実装

API 側の認証は Azure Functions (App Service) の App Service 認証 (EasyAuth) が使えそう。EasyAuth はアプリの前段にトークン検証と検証結果をヘッダーに詰めるリバプロっぽいレイヤーを追加する機能で、openid-configuration の URL と aud だけとりあえず指定すれば、良い感じに動いてくれる。

ID プロバイダーの追加

GitHub Actions の openid-configuration は [https://token.actions.githubusercontent.com/.well-known/openid-configuration] なので、こんな感じ。

GitHub Actions を ID プロバイダーとして追加

外部 OpenID Provider を指定したときはなぜか aud を指定できないので、クライアント ID に GitHub Actions で指定した audience のパラメータを指定する。この辺、EasyAuth の動きがイマイチ分かっていない。シークレットは使わないのに必須なので、適当に入れる。

こんな感じに設定すれば GitHub Actions の ID トークンが Authorization ヘッダーに追加された状態で API を叩くと、X-MS-CLIENT-PRINCIPAL-ID に ID トークンの sub が入ってくるので、あとはこんな感じのコードを追加して完成。

    public static class sampleapi
    {
        private static string ALLOWED_REPO_NAME = "watahani/easyauth-github-actions-sample";

        [FunctionName("sampleapi")]
        public static async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)] HttpRequest req,
            ILogger log)
        {
            var sub = req.Headers["X-MS-CLIENT-PRINCIPAL-ID"].ToString();
            //sub の形式は repo:{organization_name}/{repository_name}:{トリガーによっていろいろ}
            var repoName = sub.Split(":")[1];
            if (repoName != ALLOWED_REPO_NAME)
            {
                log.LogWarning($"UnAuthorized access from {repoName}, sub: {sub}");
                return new ContentResult()
                {
                    ContentType = "application/json",
                    Content = "{ \"message\": \"this repository does not have permissions to call this api\"}",
                    StatusCode = StatusCodes.Status401Unauthorized,
                };

            }
            return new ContentResult()
                {
                    ContentType = "application/json",
                    Content = "{ \"message\": \"you access from authorized repository: ${repoName}\"}",
                    StatusCode = StatusCodes.Status200OK,
                };
        }
    }

EasyAuth では ID トークンの sub クレームを X-MS-CLIENT-PRINCIPAL-ID ヘッダーに挿入してくれる。ID トークンのデコードのコードすらしなくて良いので大変助かる。

GitHub のドキュメントを読む限り、sub クレームをカスタマイズした場合でも必ず repo:{organization_name}/{repository_name}:{additional_info} の形式なので、雑に文字列比較を実施した。間違っていたらだれか指摘してほしい。

In your cloud provider’s OIDC configuration, configure the sub condition to require that claims must include specific values for repo, context, and job_workflow_ref.

This customization template requires that the sub uses the following format: repo:<orgName/repoName>:environment::job_workflow_ref:. For example: “sub”: “repo:octo-org/octo-repo:environment:prod:job_workflow_ref:octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main”

https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#example-requiring-a-reusable-workflow-and-other-claims

問題点

さて、ここまで実装を検討して、重大なミスに気づいていなかった。それはこの前提条件…。

今回はリポジトリの運用上、フォーク先からの PR を受け入れる。PR が開かれたときにも GitHub Actions が走る想定だったのだが、pull_request_target で発火される Action では GitHub の ID トークンが使えないのだ。
Action 内で GitHub の id_token を発行するには id-token: write の permission が必要だが、fork 先からの PR では max の権限が id-token: read となるので、フォーク先からの PR 時にはせっかくセキュアに保護した API が叩けない。


https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token

つまり、以下の例だと、フォーク先のリポジトリからの PR では federated_token が取得できず、API 呼び出しのためのトークンが利用できない。

name: Call Protected API
on:
  pull_request_target:

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      id-token: write # write を指定しても上限の read 権限しか付与されない
      contents: read
    steps:
      - run: sleep 5 # there's still a race condition for now
      - name: Aquire ID Token
        run: |
          federated_token=`curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://githubactions" | jq -r '.value'` # ← これが叩けずに、必要なトークンが取れない
          curl -H "Authorization: Bearer $federated_token" $FUNCTION_URL 
        env:
          FUNCTION_URL: ${{ secrets.FUNCTION_URL }}

/(^o^)\

まとめると、GitHub ID トークンで API の保護ができるのは、リポジトリ内で完結するアクションだけとなる。

問題の回避方法

Azure に関する話はここまでで、まとめとしては GitHub ID トークンで、GitHub Actions 上のみで叩ける API を Azure Functions で簡単に実装できるよ、ということと Fork 先からの Pull Request に対しては ID トークン発行できないよ、という落とし穴に注意、ということでした。

Advent Calender のネタとしては以上なのだが、一応検討した回避策と最終的な実装についてもメモしておく。
Fork 先からの Merge Request で ID トークンが発行できない以上、回避策として以下が考えられる。

  1. 素直に事前共有シークレットなり証明書なりで認証する
  2. 別の Action で GitHub ID トークンを取得しておいて、Secret 経由で引き回す
  3. 別の Action で GitHub ID トークンと別のトークンを引き換えて、Secret 経由で引き回す

1 つめは、そもそも GitHub ID トークンの使い方を調べるために実装しているので却下。2 つめは GitHub ID トークンの有効期限が短いため、取り回しづらいのと Action から別の Action を呼ぶ実装方法がイマイチわからなかったので却下。ということで 3 つめを採用することにする。

別のトークンとして何を採用するかだが、方法としては Azure AD などの OpenID Provider を経由して GitHub トークン → Azure AD のトークンと引き換えて、シークレット経由で引き回す方法と、API 側で適当な認証トークンを発行する方法を考えた。しかし、1 つめの方法だと結局トークンの有効期限がネックとなるのと、AAD の場合 sub が完全一致しかサポートされていないので「特定のリポジトリから」というざっくりとした設定が出来ないこと、そしてアプリ側で管理すると更新が面倒 (許可リポジトリもソース コード上で管理したい) ということで却下。API 側でトークンを発行し Secret に保存する方式とした。

最終的な構成

最終的な実装

Functions 側は .NET6 を使い、こんな感じで Token エンドポイントを生やして、特定のリポジトリの GitHub ID Token が提示された場合にのみトークンを発行することとした。

    public class GetTokenService
    {
        private readonly IHttpContextAccessor _httpContextAccessor;
        private readonly MyTokenHandler _handler;
        private ILogger _log;

        public GetTokenService(MyTokenHandler handler)
        {
            _handler = handler;
        }
        [FunctionName("token")]
        public async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "auth/token")] HttpRequest req, ILogger log)
        {
            _log = log;
            if (!req.HttpContext.User.Identity.IsAuthenticated)
            {
              return new UnauthorizedResult();
            }
            var tokenSubject = "test";

            if (req.Headers["X-MS-CLIENT-PRINCIPAL-IDP"] == "GitHubActions")
            {
                var sub = req.Headers["X-MS-CLIENT-PRINCIPAL-ID"].ToString();
                var repoName = sub.Split(":")[1];
                if (!Utils.IsAuthorizedRepository(repoName, allowedRepoNames))
                {
                    log.LogWarning($"UnAuthorized access from {repoName}, sub: {sub}, allowedRepoNames: {String.Join(",", allowedRepoNames)}");
                    return new ContentResult()
                    {
                        ContentType = "application/json",
                        Content = "{ \"message\": \"this repository does not have permissions to call this api\"}",
                        StatusCode = StatusCodes.Status401Unauthorized,
                    };
                }
                log.LogInformation($"Authorized access from {repoName}");
                tokenSubject = repoName;
            }

            var tokenResult = _handler.GenerateToken(tokenSubject);
            return new ContentResult()
            {
                ContentType = "application/json",
                Content = JsonSerializer.Serialize(tokenResult, options: TokenResult.options)
            };
        }

    }

実際の API 側では、未認証の時にだけ Authorization ヘッダーを検証し、独自のトークンが提示されれば認証 OK とする。

        if (!req.HttpContext.User.Identity.IsAuthenticated)
        {
            log.LogInformation("this request haven't passed app service authentication");
            var authorizationHeader = req.Headers["Authorization"];
            log.LogInformation($"Authorization Header: {authorizationHeader}");
            if (!_handler.VerifyHeader(authorizationHeader))
            {
                return new UnauthorizedResult();
            }
        }
        //実際の処理をここに書く

トークンは適当な乱数でも良いのだが、データベースを用意していなかったので HS256 の JWT にした。コードは以下のブログを参考にした。

参考: Creating And Validating JWT Tokens In C# .NET - .NET Core Tutorials

update-credential Action の実装は非常にシンプル。GitHub Actions の SDK に getIDToken メソッドが生えているので、ID トークンを取得して、上記の Token Endpoint を叩いた後、Output としてトークンを出力する Task を以下の様に実装する。

const core = require('@actions/core');
const axios = require('axios');
const constants = require('./constants')

async function run() {

  try {
    const githubToken = await core.getIDToken(constants.AUDIENCE);

    const headers = {
      Authorization: `Bearer ${githubToken}`
    }

    const tokenResult = await axios.get(constants.TOKEN_ENDPOINT, {
      headers: Object.assign(headers, constants.GITHUB_TRACES)
    })

    const token = tokenResult.data.value;

    core.setOutput("token", token);

  } catch (error) {
    console.log(error)
    core.setFailed(error.message);
  }
}

run();

その後出力された token を gh secret set でリポジトリの SECRET に書き込みを実施する。この際の認証情報は GitHub App なり、PAT なりを使って頑張ること。
メインの GitHub Actions 側では、SECRET に格納されたトークンを取ってきて API を叩けば実装完了。

GitHub Actions 周りの細かい実装を見たい人はこの辺のリポジトリ を見て欲しい。

宣伝

ということで、出来上がったのが弊 Azure AD チームのブログでのプレビュー機能でした。結局適当なシークレットを使ったほうが実装が楽だった説はありますが、まあそんなこともあるよねということで。

Azure AD のサポートチームのブログはめちゃくちゃ更新頻度も高く、良質な記事がいっぱいなのでフォローしてくれよな。

https://jpazureid.github.io/blog/

明日は、同じサポート チームの @oliva が何か書くそうです。今年もゆるく募集しましたが、いろんな人が参加してくれて嬉しいですね。片っ端からフォローしてます。

関連記事