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

記事を書きました。こちらの方がまとまっています。
サービスアカウントの作成
これはGitHub Actionsが最終的に持つようになる権限を持つサービスアカウントを作成した。
Workload Identityプールの作成
Workload Identityたるものを作成した。
Google Cloudを外部から利用したい時、サービスアカウントのキーを発行することで実現できる。これはAWSでいうアクセスキー・シークレットキーだ。
しかし、当然ながら有効期限の長いこうしたキーを用いることは、万が一流出してしまった場合に危険。そこで Workload Identity連携
を行うと、サービスアカウントキーを発行することなく外部サービスが安全にGCPを利用できるようになる。
仕組みとしてはこうだ。
- 外部ワークロードはIdPと連携し、認証トークンを取得する
- 取得した認証トークンを使って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ユーザ
のロールを割り当てている
このロールの割り当てが良くわからない。

というわけでactionsのコードの中身を見てみよう
メモがぶっ飛んだ気がする。
こちらの miyajan さんの記事を参考にコードを読んでいく。
まずは、GCPでOIDCを利用したAPI呼び出し許可を行うときに使うgoogle-github-actions/auth
アクションの中を読んで見る。
見たいのは、【OIDCトークンを取得するコード】。
main.ts
を見ると @actions/core
の getIDToken
という関数をインポートしているのが分かる。
token_format = id_token
を指定したときのものです。でも基本的には token_format
は指定しないことも多いと思います。その場合は以下の流れが全部ウソなので注意。指定しなかった場合の流れはずっと後に追記しています。
※ここから書いているのは 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プロバイダの認証に使われるトークンなんだろう。

帰ってきたIDトークン(戻り値)を見てみる
せっかくなので帰ってきた値を見てみる。
と思ったけど miyajan さんの記事の中で十分だった。

IDトークンの行方
githubから発行されたIDトークンを使って、最終的にはGoogle Cloud上のリソースを利用できるようになるわけだが、その間に行われる処理はこちら。
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}`);
}
}

整理する
長くなったので整理する。
今までの流れ
- GitHub Actions上でGitHub OIDCトークンを発行した
- 発行した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のアクセストークン
もうちょっと詳しく
とにかくこれは
- GitHubなどの外部OIDCトークンと交換で手に入る
-
OAuth2.0 security token
である- ※OAuthの"アクセストークン"と同義だと思われる
4. token
アクセストークンを使って手に入れたトークン。
これが最終的に欲しかった(Google Cloud操作の認可)もの??
もうちょっと詳しく
APIドキュメントを読んでみると、発行されるのはOpenID Connect token for a service accout
をらしい
…おや。これもOIDCトークンか。
つまりGitHub ActionsからGoogle Cloudを操作できるようになるまでに、OIDCは2回登場している。
また、この記事を読むと
- Obtain a credential from the trusted identity provider, for example an OpenID Connect ID token.
- Use the STS API to exchange the credential against a short-lived Google STS token.
- 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トークンを取得する、という流れだと思う。

やばいコードを読み間違えていた。
token_format = id_token
を指定していた場合の流ればかり追っていた。
でも、デフォルトの使い方としては指定しない(空)です。
その場合のコードの流れは上記で書いた流れと違うので、今から追記していきます。