Open6

GitHub ActionsとGoogle CloudのOIDCを理解したい

takamin55takamin55

記事を書きました。こちらの方がまとまっています。

https://zenn.dev/takamin55/articles/53d732b081ba66




サービスアカウントの作成

これはGitHub Actionsが最終的に持つようになる権限を持つサービスアカウントを作成した。

Workload Identityプールの作成

Workload Identityたるものを作成した。

https://cloud.google.com/blog/ja/products/identity-security/enable-keyless-access-to-gcp-with-workload-identity-federation?hl=ja
Google Cloudを外部から利用したい時、サービスアカウントのキーを発行することで実現できる。これはAWSでいうアクセスキー・シークレットキーだ。

しかし、当然ながら有効期限の長いこうしたキーを用いることは、万が一流出してしまった場合に危険。そこで Workload Identity連携を行うと、サービスアカウントキーを発行することなく外部サービスが安全にGCPを利用できるようになる。

仕組みとしてはこうだ。

  1. 外部ワークロードはIdPと連携し、認証トークンを取得する
  2. 取得した認証トークンを使ってSTSを呼び出し、有効期間が短いGCPアクセストークン(予め設定したサービスアカウントの権限を持つ)と交換する

Workload Identityプールは、外部ワークロードで発行されたIDのコレクション(管理)用のコンテナとして機能するらしい。
そのため、環境(開発・ステージング・本番など)毎に1つある状態が無難。

Workload Identityプロバイダの作成

プールで管理するプロバイダを作成する。
プロバイダとは提供する者。何を提供するかというと先程書いた通り認証トークンのこと。正確にはIdentity Provider, つまりIdPのこと。

認証トークンを発行する者(プロバイダ)は AWS, OIDC, SAMLから選ぶ。

プロバイダを作成する時、発行元URL(issuer url)というものを指定する。先程のプロバイダが種類を選んだようなイメージで、こちらは具体的に発行するIdPを一意に識別するURLを指定する。

属性マッピング

以下のようなマッピングを選択することになる。これは一体何か。

  • google.subject - assertion.sub
  • attribute.repository - assertion.repository
  • attribute.actor - assertion.actor

先程も書いた通り、IdPが発行する認証トークンを使って短命アクセストークンを発行することが目的だが、その際にIdP側で発行された認証トークンの内容とGoogleが欲しい情報が食い違っているので、そのマッピングを行っている。

Workload Identityプールがサービスアカウントへのアクセスを許可

ここが良くわからない。

最初に作ったサービスアカウントに対して、

  • Workload IdentityプールのIAMプリンシパルに
  • Workload Identityユーザのロールを割り当てている

このロールの割り当てが良くわからない。

takamin55takamin55

というわけでactionsのコードの中身を見てみよう

メモがぶっ飛んだ気がする。
こちらの miyajan さんの記事を参考にコードを読んでいく。

https://zenn.dev/miyajan/articles/github-actions-support-openid-connect#概要

まずは、GCPでOIDCを利用したAPI呼び出し許可を行うときに使うgoogle-github-actions/authアクションの中を読んで見る。
https://github.com/google-github-actions/auth

見たいのは、【OIDCトークンを取得するコード】。
main.tsを見ると @actions/coregetIDTokenという関数をインポートしているのが分かる。


※ここから書いているのは token_format = id_tokenを指定したときのものです。でも基本的には token_formatは指定しないことも多いと思います。その場合は以下の流れが全部ウソなので注意。指定しなかった場合の流れはずっと後に追記しています。

main.ts
import {
  ...
  getIDToken, 
  ...
} from '@actions/core';

そのコードの説明は miyajan さんの記事にあるとおりだが、自分は自分の理解のために何が起きているかを書いていく。

let id_token_url: string = OidcClient.getIDTokenUrl()

ここで、OIDCの流れをおさらいすると、リライング・パーティIDプロバイダに対してトークンリクエストを投げ、そのお返しでアクセストークンとIDトークンを貰うというものだったから、URLとはIDプロバイダのURLだろう。関数名からもそうだと分かる。

getIDTokenUrl()の中身を見てみると、

private static getIDTokenUrl(): string {
    const runtimeUrl = process.env['ACTIONS_ID_TOKEN_REQUEST_URL']
    if (!runtimeUrl) {
      throw new Error('Unable to get ACTIONS_ID_TOKEN_REQUEST_URL env variable')
    }
    return runtimeUrl
  }

となっているので、ACTIONS_ID_TOKEN_REQUEST_URLの値を唯返しているとわかるが、この値こそがIDプロバイダのURLだと分かる。どこなんだろう。興味が出てきた。

echoしてみたら https://pipelinesghubeus13.actions.githubusercontent.com/WArxNJnd...... のようなURLが出てきた。思ってたのと違う。

続いて、以下のコード。

if (audience) {
    const encodedAudience = encodeURIComponent(audience)
    id_token_url = `${id_token_url}&audience=${encodedAudience}`
}

audienceが設定されていたら、クエリパラメータにそれを付与するコードになっている。
audとはIDトークンの発行が誰に向けたもの(=発行を依頼したのは誰か?)なので、ここには GCPのworkload_identity_providerの識別子が入る。

workload_identity_provider: (Required) The full identifier of the Workload Identity Provider, including the project number, pool name, and provider name. If provided, this must be the full identifier which includes all parts:
https://github.com/google-github-actions/auth/tree/main#authenticating-via-workload-identity-federation

最後にこれ

const id_token = await OidcClient.getCall(id_token_url)

さっきのURLに向けて、ACTIONS_ID_TOKEN_REQUEST_TOKENをAuthenticationにセットしたHTTPリクエストを投げる。戻り値はご期待のIDトークン。

ACTIONS_ID_TOKEN_REQUEST_TOKENは secret なので echo しても値は見れなかったが、まぁIDプロバイダの認証に使われるトークンなんだろう。

takamin55takamin55

帰ってきたIDトークン(戻り値)を見てみる

せっかくなので帰ってきた値を見てみる。
と思ったけど miyajan さんの記事の中で十分だった。

takamin55takamin55

IDトークンの行方

githubから発行されたIDトークンを使って、最終的にはGoogle Cloud上のリソースを利用できるようになるわけだが、その間に行われる処理はこちら。

https://github.com/google-github-actions/auth/blob/e607103ba47e9d2221c6f81b7cf0b4699fa2f055/src/main.ts

ase 'id_token': {
      logDebug(`Creating id token`);

      const idTokenAudience = getInput('id_token_audience', { required: true });
      const idTokenIncludeEmail = getBooleanInput('id_token_include_email');
      const serviceAccount = await client.getServiceAccount();

      const authToken = await client.getAuthToken();
      const { token } = await client.googleIDToken(authToken, {
        serviceAccount,
        audience: idTokenAudience,
        delegates,
        includeEmail: idTokenIncludeEmail,
      });
      setSecret(token);
      setOutput('id_token', token);
      break;
    }

clientの中にOIDCトークンがセットされている。
ようはclient.getAuthToken()client.googleIDToken()でGoogle Cloud操作に必要なトークンを取得し、それをsecretやoutputに設定しているわけだ。

client.getAuthToken()

async getAuthToken(): Promise<string> {
    const pth = `https://sts.googleapis.com/v1/token`;

    const data = {
      audience: '//iam.googleapis.com/' + this.#providerID,
      grantType: 'urn:ietf:params:oauth:grant-type:token-exchange',
      requestedTokenType: 'urn:ietf:params:oauth:token-type:access_token',
      scope: 'https://www.googleapis.com/auth/cloud-platform',
      subjectTokenType: 'urn:ietf:params:oauth:token-type:jwt',
      subjectToken: this.#token,
    };

    const headers = {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
    };

    try {
      const resp = await this.client.request('POST', pth, JSON.stringify(data), headers);
      const body = await resp.readBody();
      const statusCode = resp.message.statusCode || 500;
      if (statusCode >= 400) {
        throw new Error(`(${statusCode}) ${body}`);
      }
      const parsed = JSON.parse(body);
      return parsed['access_token'];
    } catch (err) {
      throw new Error(
        `Failed to generate Google Cloud federated token for ${this.#providerID}: ${err}`,
      );
    }
  }

GitHub OIDCトークンを使って、Google CloudのSTSを叩いている。そしてアクセストークンを取得している。アクセストークンについては後で調べる。

client.googleIDToken()

引数のtokenにはさっき発行したaccess_tokenが入る。
https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${serviceAccount}:generateIdTokenに対してリクエストを投げて、tokenを取得している

async googleIDToken(
    token: string,
    { serviceAccount, audience, delegates, includeEmail }: GoogleIDTokenParameters,
  ): Promise<GoogleIDTokenResponse> {
    const pth = `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${serviceAccount}:generateIdToken`;

    const data = {
      delegates: delegates,
      audience: audience,
      includeEmail: includeEmail,
    };

    const headers = {
      'Authorization': `Bearer ${token}`,
      'Accept': 'application/json',
      'Content-Type': 'application/json',
    };

    try {
      const resp = await this.client.request('POST', pth, JSON.stringify(data), headers);
      const body = await resp.readBody();
      const statusCode = resp.message.statusCode || 500;
      if (statusCode >= 400) {
        throw new Error(`(${statusCode}) ${body}`);
      }
      const parsed = JSON.parse(body);
      return {
        token: parsed['token'],
      };
    } catch (err) {
      throw new Error(`failed to generate Google Cloud ID token for ${serviceAccount}: ${err}`);
    }
  }
takamin55takamin55

整理する

長くなったので整理する。

今までの流れ

  1. GitHub Actions上でGitHub OIDCトークンを発行した
  2. 発行したOIDCトークンを使ってGoogle Cloudを操作できるトークンを発行した

出てきた4種類のトークンとAPI

1. ACTIONS_ID_TOKEN_REQUEST_TOKEN

GitHubのIDプロバイダのAPIを叩くための認証トークン。

2. id_token

GitHubのIDプロバイダから返されたトークン。いわゆるOIDCトークンとかOIDCのIDトークンとか。

3. access_token

OIDCトークンを使ってGoogle CloudのSTSから受け取ったOAuth2.0のアクセストークン

もうちょっと詳しく
https://cloud.google.com/iam/docs/reference/sts/rest/v1/TopLevel/token

とにかくこれは

  • GitHubなどの外部OIDCトークンと交換で手に入る
  • OAuth2.0 security tokenである
    • ※OAuthの"アクセストークン"と同義だと思われる

4. token

アクセストークンを使って手に入れたトークン。
これが最終的に欲しかった(Google Cloud操作の認可)もの??

もうちょっと詳しく
https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateIdToken

APIドキュメントを読んでみると、発行されるのはOpenID Connect token for a service accoutをらしい
…おや。これもOIDCトークンか。

つまりGitHub ActionsからGoogle Cloudを操作できるようになるまでに、OIDCは2回登場している。

https://cloud.google.com/blog/en/products/identity-security/how-to-authenticate-service-accounts-to-help-keep-applications-secure?hl=en
また、この記事を読むと

  1. Obtain a credential from the trusted identity provider, for example an OpenID Connect ID token.
  2. Use the STS API to exchange the credential against a short-lived Google STS token.
  3. Use the STS token to authenticate to the IAM Credentials API and obtain short-lived Google access tokens for a service account.

と書かれているので、アクセストークンはIAM Credential APIへのアクセスが認可されていて、そのアクセストークンを使ってIAM Credential APIを叩いてService Accountに対応する(の権限を持つ)IDトークンを取得する、という流れだと思う。

takamin55takamin55

やばいコードを読み間違えていた。
token_format = id_tokenを指定していた場合の流ればかり追っていた。

でも、デフォルトの使い方としては指定しない(空)です。
https://github.com/google-github-actions/auth#usage

その場合のコードの流れは上記で書いた流れと違うので、今から追記していきます。