LayerX エンジニアブログ

LayerX の エンジニアブログです。

Microsoft Graph API へのキーレス認証 (GitHub Actions編) #LayerXテックアドカレ

7月ぐらいから CTO 室で働き始めている @yuya-takeyama です。

Corporate Engineering や Security に関わる仕事などをしています。

この記事はLayerXテックアドカレ2023の6日目の記事です。 昨日は機械学習を通じて誰かをラクにしたい yakipuさんが「〜OCR戦記〜適格事業者登録番号との戦い🔥🔥🔥」を書いてくれました。 次回は civitaspo さんが素敵な記事を書いてくれます。

Microsoft Graph APIについて

Microsoft Graph APIというのは、Microsoft 365 を始めとする Microsoft 社の様々なクラウドサービスのデータにアクセスするための API 群です。

私たちの場合は Identity Provider として使っている Microsoft Entra ID (formerly known as Azure AD) のデータにアクセスするために使うことが多いです。例えば入社時のアカウント発行や退社時のアカウント停止などにおける自動化されたパイプラインがあり、その中で使っていますし、今後もポジション変更などより様々なライフサイクルにおける自動化だったり、不整合の検知など、さまざまな場面に利用が広がることが予想されます。

そんなわけでゴリゴリとコードを書いていきたいところですが、2023年の世界においては ChatGPT や GitHub Copilot といった LLM に支援を受けながらバリバリとやっていきたいところです。が、直近で私がやった範囲では実際には動作しないコードが生成されたりと、良い支援を受けられていないように感じます。AWS SDK を使ったコードなどと比べると、まだそれほど利用が広がっていないために、学習が足りていないのではないかと想像できます。

そこで、皆さんがもっと Microsoft Graph を使った自動化の仕組みを楽に作れるように、サンプルコードも交えながらこの記事を書こうというと思った次第です。

キーレス認証とは

名前の通りパスワードや API key のような鍵を使わない認証方式ですが、この記事の文脈においては OpenID Connect を用いた認証を指します。

API Key やパスワードのような、固定の鍵が存在しないため、その鍵が漏れることによる攻撃のリスクをなくすことができます。個人的には、そういった鍵を安全な場所 (例: AWS Secrets Manager) に保管して利用時には取り出して、といった取り回しが不要になるのはとても楽で良いなとも思っています。

近年は GitHub Actions からの AWS や Google Cloud の API へのサービスに対してこの認証方式が広がっていますが、それを Microsoft Graph に対してもやろう、というのが今回の記事です。

なお、今回の記事は GitHub Actions 編となっており、GitHub Actions 上でアプリケーションを実行する場合についての説明ですが、後日 AWS 編の記事を別で出せればと思っています。

Go での Microsoft Graph API のためのライブラリ

大きく 2 つあり、この記事ではその両方のためのサンプルコードを掲載していきます。

github.com/microsoftgraph/msgraph-sdk-go

URL を見てわかる通り、Microsoft Graph 公式の SDK です。

やはり公式という安心感は大きいですし、直近のリリース頻度も比較的高く、現状の GitHub 上のスター数もこの後紹介るものと比べて倍以上なので、こちらの方がよく使われいていそうに見えます。

github.com/manicminer/hamilton

一方こちらは非公式で、メンテナンスも Organization ではなくほぼ個人で行なっているように見えます。

ですが、このライブラリは Hashicorp 公式による terraform-provider-azuread でも使われています。

加えて、両方のリポジトリは事実上同一のメンテナによってメンテナンスされています。

Hashicorp 社によるメンテナンスの継続性をある程度期待しても良さそうですが、ここは利用者それぞれで検討して判断を下すのが良いでしょう。

https://github.com/hashicorp/terraform-provider-azuread/blob/v2.45.0/go.mod#L12

また、v0.58.0 からは認証等の部分を自前のコードから Hashicorp 社オフィシャルの azure-sdk-go に依存するようになっていたりします。

https://github.com/manicminer/hamilton/blob/v0.65.0/go.mod#L6

いずれも少し使う範囲ではそう大きくは変わらないので、それぞれで判断して良いと思う方を使うのが良いでしょう。

LayerX 社内では現在両者がバラバラに使われており、msgraph-sdk-go に統一したいとは思っています。

Federated credential の設定

ここからは、実際に動くものを作っていきます。

まずはいずれのライブラリを使っていても共通の作業として、Federated credentials を作成します。

といっても基本は以下のドキュメント通りで問題ありません。

Register your app with the Azure AD v2.0 endpoint - Microsoft Graph

こちらに従って Application (App registration) を作成し、Federated credential を作成・設定します。

ここでは Pull Request 内で実行するものとして設定を行いますが、特定のブランチやタグの名前で指定することも可能です。

Federated credentialの設定画面

ただし、Pull Request に対して権限を与える場合、そのリポジトリに Pull Request を作れる全員が、事実上その App registration と同等の権限を持つことになる、ということには注意が必要です。Pull Request を作る権限は適切な人・グループのみに与えられているか、App registration に持たせる権限は必要最小限になっているか、に注意しましょう。

ブランチを指定する場合、例えば main ブランチを指定して、GitHub の protected branch 上適切な人の approve を必須にしたりすることで、セキュリティを強化することも可能です。運用上は Pull Request とブランチとで別の App registration (≒別の権限) を使い分けるべきケースも多いでしょう。

API Permissions の設定

ここではこのあとの実装例に合わせて Microsoft Graph の Application permissions として User.Read.All を指定します。

実際は要件に合わせた最小の権限を指定するようにしましょう。

また、ここの権限の追加は管理者による同意が必要な点にも留意しましょう。

Grant tenant-wide admin consent to an application

msgraph-sdk-go におけるキーレス認証の実装

以下は yuya@example.com というメールアドレスのユーザーを取得して、その Display Name を出力するだけの簡単なプログラムです。

package main

import (
    "context"
    "fmt"

    "github.com/Azure/azure-sdk-for-go/sdk/azidentity"
    msgraphsdk "github.com/microsoftgraph/msgraph-sdk-go"
)

func main() {
    cred, _ := azidentity.NewDefaultAzureCredential(nil)
    client, err := msgraphsdk.NewGraphServiceClientWithCredentials(cred, []string{"https://graph.microsoft.com/.default"})
    if err != nil {
        panic(err)
    }

    user, err := client.Users().ByUserId("yuya@example.com").Get(context.Background(), nil)
    if err != nil {
        panic(err)
    }

    fmt.Println(*user.GetDisplayName())
}

ここで着目すべきは azidentity.NewDefaultAzureCredential(nil) です。

ここは使用するクレデンシャルの種類に応じて特化した struct を用いることも可能ですが、これを利用することで環境変数等に応じて認証方式を柔軟に切り替えることが可能です。

例えばローカルでの開発時には Azure CLI でログインし、GitHub Actions 上では OIDC によるキーレス認証を行ったり、といった具合です。

ここは要件に応じてむしろ意図した認証方式以外使えないようにした方が良いケースもあるでしょう。

次に、これを GitHub Actions 上で実行するための Workflow です。

(都合上 main.gomsgraph-sdk-go というディレクトリ内に存在するという前提になっています)

name: msgraph-sdk-go

on:
  pull_request:
  push:
    branches:
      - main

jobs:
  run:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v4
        with:
          go-version-file: msgraph-sdk-go/go.mod
          cache-dependency-path: msgraph-sdk-go/go.sum
      - id: github-oidc-token
        run: |
          token_file="$(mktemp)"
          curl -s --fail -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://AzureADTokenExchange" | jq .value -r > "$token_file"
          echo "token-file=$token_file" >> "$GITHUB_OUTPUT"
      - run: |
          cd msgraph-sdk-go
          go run main.go
        env:
          AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
          AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
          AZURE_FEDERATED_TOKEN_FILE: ${{ steps.github-oidc-token.outputs.token-file }}

ここでまず着目すべきは id: github-oidc-token のステップです。

ここでは GitHub Actions の Identity Provider から JWT (JSON Web Token) を生成しています。

これを元に Microsoft Graph API へのフェデレーテッド認証を行います。

permissionsid-token: write にしておく必要があることにも注意が必要です。

詳細はこちらのドキュメントを参照してください。

About security hardening with OpenID Connect - GitHub Docs

そして、プログラム実行時の AZURE_ 始まりの各種環境変数です。

これらはいずれも、msgraph-sdk-go の認証に用いるライブラリ azure-sdk-for-go における azidentity というパッケージで既定のものです。

AZURE_CLIENT_ID には認証に使用する App registration の Application (client) ID を、AZURE_TENANT_ID には Microsoft Entra ID の Tenant ID を持たせます。

ここではリポジトリの設定として Variables に予め持たせています。

どの App registration を使っているのかわからなくならないよう、こういった値は Secrets ではなく Variables に入れるのが個人的に好みです。

AZURE_FEDERATED_TOKEN_FILE には JWT が保存されていて WorkloadIdentityCredential という struct のなかで使用されます。

うまくいけば指定したユーザーの display name が出力されます。

強いていえば必要な処理の完了時に JWT の無効化ができればさらに安全性が高められそうですが、こちらで調べた限りは方法が見つかりませんでした。

(発行された JWT を実際に確かめたところ、有効期間は 5 分だけだったのでそんなに気をつける必要もないかもですが)

hamilton におけるキーレス認証の実装

msgraph-sdk-go の例と同様のものを用意しました。

package main

import (
    "context"
    "fmt"
    "os"

    "github.com/hashicorp/go-azure-sdk/sdk/auth"
    "github.com/hashicorp/go-azure-sdk/sdk/environments"
    "github.com/hashicorp/go-azure-sdk/sdk/odata"
    "github.com/manicminer/hamilton/msgraph"
)

func main() {
    ctx := context.Background()
    env := environments.AzurePublic()

    credentials := auth.Credentials{
        Environment: *env,
    }

    if os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL") != "" {
        credentials.EnableAuthenticationUsingGitHubOIDC = true
        credentials.ClientID = os.Getenv("AZURE_CLIENT_ID")
        credentials.TenantID = os.Getenv("AZURE_TENANT_ID")
        credentials.GitHubOIDCTokenRequestURL = os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL")
        credentials.GitHubOIDCTokenRequestToken = os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN")
    } else {
        credentials.EnableAuthenticatingUsingAzureCLI = true
    }

    authorizer, err := auth.NewAuthorizerFromCredentials(ctx, credentials, env.MicrosoftGraph)
    if err != nil {
        panic(err)
    }

    client := msgraph.NewUsersClient()
    client.BaseClient.Authorizer = authorizer

    user, _, err := client.Get(ctx, "yuya@example.com", odata.Query{})
    if err != nil {
        panic(err)
    }

    fmt.Println(*user.DisplayName)
}

msgraph-sdk-go と違って、既定の環境変数によっていい感じに認証方式を切り替えたりする機能はなさそうなので、存在する環境変数に応じて分岐を入れています。

その他の認証方式にも対応させる場合はよしなにする必要があります。

そして次にこれを実行する GitHub Actions の Workflow。

name: hamilton

on:
  pull_request:
  push:
    branches:
      - main

jobs:
  run:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v4
        with:
          go-version-file: hamilton/go.mod
          cache-dependency-path: hamilton/go.sum
      - run: |
          cd hamilton
          go run main.go
        env:
          AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
          AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}

こちらは msgraph-sdk-go と違って、JWT の取得を行うステップは不要です。

$ACTIONS_ID_TOKEN_REQUEST_URL$ACTIONS_ID_TOKEN_REQUEST_TOKEN を渡せば go-azure-sdk の中でよしなにしてくれます。

このように環境変数周りにいい感じのデフォルトが提供されていないところが若干面倒ですが、同じく hamilton を使って実装された terraform-provider-azuread ではいい感じに設定できるようになっているので、Terraform で Microsoft Entra ID 周りのリソースを管理したい場合は以下のドキュメントを参照してください。

Authenticating using a Service Principal and OpenID Connect

まとめ

以上、GitHub Actions から Microsoft Graph API へのキーレス認証を行う方法について説明しました。

次はまた別の機会に AWS から Microsoft Graph API をキーレス認証を用いて利用する方法について説明できればと思います。

そちらはさらに追加での AWS リソースのセットアップ等必要にはなるんですが、AWS Lambda 等を用いてより多様な自動化を作りこむためには便利な情報なのではないかと思います。

それではまたの機会に。