Goを使ってGCPのサービス間認証をする
はじめに
Cloud Run便利ですよね。コンテナをデプロイするだけで勝手にスケールしてくれますし使わない時はゼロインスタンスに出来ますし。こうした取り回しの便利さに加えて「良いな」と個人的に機能の一つがアカウントレベルのアクセスコントロールです。
Authenticationを「未認証を許可」にすれば公開APIとして誰でも使える状態になりますし、ACLの設定をすればサービスアカウントやユーザアカウント単位で実行できるユーザやアプリを限定出来ます。こういったセキュリティをアプリ側で作りこまなくて良いのは楽ですよね。
今回ちょっとIdentity Tokenをどう取り出すのか? でハマってので「どのように認証をするのか」「IDトークンをどのように取得するのか」をメモがてら書いていきたいと思います。
なお、前半は結果として不要だった調査の話なので結論だけ知りたい人は後半へGo。
TL;DR
- ADCやJWSライブラリからIdentity Tokenを取り出す方法
- そもそもidtoken.NewClientをしろ
- ドキュメントは良く読もう
サービス間認証の概要
とりあえずサービス間認証に関して軽く確認しましょう。公式マニュアルはこちら
基本的には OAuth ID トークンによる認証を行っているようです。そのため何の認証情報も負荷せずに投げると以下のようなエラーになります。
❯ curl -i https://target-xxx.a.run.app/healthcheck
HTTP/2 403
date: Thu, 17 Jun 2021 14:48:11 GMT
content-type: text/html; charset=UTF-8
server: Google Frontend
content-length: 306
alt-svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000,h3-T051=":443"; ma=2592000,h3-Q050=":443"; ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000,quic=":443"; ma=2592000; v="46,43"
<html><head>
<meta http-equiv="content-type" content="text/html;charset=utf-8">
<title>403 Forbidden</title>
</head>
<body text=#000000 bgcolor=#ffffff>
<h1>Error: Forbidden</h1>
<h2>Your client does not have permission to get URL <code>/healthcheck</code> from this server.</h2>
<h2></h2>
</body></html>
面白いのはエラーを返しているのがGoogle Frontendであるという事。つまり、この時点ではアプリまではそもそもリクエストが行っていないのですね。なのでログとかを見るときには注意をしてください。こうしてslide-car的にいろいろしてくれるのは良いですよね。
では成功させるにはどうするか、というとOAuth ID トークンを渡せば良いのでgcloud auth print-identity-token
を使うと簡単です。それをAuthorization
ヘッダーに突っ込んでやるおなじみの方法ですね。
❯ curl -v -H "Authorization: Bearer $(gcloud auth print-identity-token)" https://target-xxx.a.run.app/healthcheck
- 中略
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* Using Stream ID: 1 (easy handle 0x558429803820)
> GET /healthcheck HTTP/2
> Host: target-xxx.a.run.app
> user-agent: curl/7.68.0
> accept: */*
> authorization: Bearer xxxxx
- 中略
{"message":"OK","date":"2021-06-17T00:00:00"}
リクエストに成功しました。簡単ですね?
では、これをGoでやってみましょう。。。と言うところでGoのプログラム中でidentity-token
を取得する方法が分からず、ちょっと迷走してしまいました。
先ほどの公式マニュアルを見てもメタデータから取得しているんですよね。
tokenURL := fmt.Sprintf("/instance/service-accounts/default/identity?audience=%s", serviceURL)
idToken, err := metadata.Get(tokenURL)
これはこれで良いのですが、ローカルで動かせなくて辛い。やはりADCを活用したいですし自力で組む方向で考えました。
ここで「あれ?」と思った方。たぶんあなたは 「正しい」 です。なのであなたには3つの選択肢があります。
a) このまま読み続ける: 次へ進む
b) 結論だけ欲しい: idtokenへ進む
c) もういいや: ブラウザをそっと閉じる
ADC
GCPの多くのライブラリはアプリケーションのデフォルト認証情報(ADC / Application Default Credentials) に対応しています。これは以下の順番で認証情報を確認しデフォルトの認証を取るものです。
- GOOGLE_APPLICATION_CREDENTIALSにパス指定されている鍵ファイルのアカウント
- デフォルトのパスにある鍵ファイルのアカウント
- GCP内の実行するリソースに紐づけられたサービスアカウント
1,2はローカルでの開発時やGCP以外の環境で主に使います。サービスアカウントの鍵ファイルを事前に取得してGOOGLE_APPLICATION_CREDENTIALS
にパスを入れるお馴染みの方式ですね。
3はGCP内の実行環境で使えます。Compute Engine、Google Kubernetes Engine、App Engine、Cloud Run、Cloud Functions などが対応していてデフォルトアカウントまたはユーザ指定のサービスアカウントを使う事が出来ます。これは非常に便利でアカウント認証情報自体をサーバのローカルやイメージ内部に持たなくて良いのでセキュアですね。
ただ、ADCを裏で使ってるライブラリはあるもののADC自体を直接使う方法をマニュアルから見つけれませんでした。仕方が無いのでGoogleのライブラリからGOOGLE_APPLICATION_CREDENTIALS
を使っている箇所を探って以下のコードを見つけました。
// FindDefaultCredentialsWithParams searches for "Application Default Credentials".
//
// It looks for credentials in the following places,
// preferring the first location found:
//
// 1. A JSON file whose path is specified by the
// GOOGLE_APPLICATION_CREDENTIALS environment variable.
// For workload identity federation, refer to
// https://cloud.google.com/iam/docs/how-to#using-workload-identity-federation on
// how to generate the JSON configuration file for on-prem/non-Google cloud
// platforms.
// 2. A JSON file in a location known to the gcloud command-line tool.
// On Windows, this is %APPDATA%/gcloud/application_default_credentials.json.
// On other systems, $HOME/.config/gcloud/application_default_credentials.json.
// 3. On Google App Engine standard first generation runtimes (<= Go 1.9) it uses
// the appengine.AccessToken function.
// 4. On Google Compute Engine, Google App Engine standard second generation runtimes
// (>= Go 1.11), and Google App Engine flexible environment, it fetches
// credentials from the metadata server.
func FindDefaultCredentialsWithParams(ctx context.Context, params CredentialsParams) (*Credentials, error) {
具体的な使い方はこんな感じ。シンプルですね。
// ADC
credentials, err := google.FindDefaultCredentials(oauth2.NoContext, AuthUrl)
if err != nil {
fmt.Println(err)
}
config, err := google.JWTConfigFromJSON(credentials.JSON)
if err != nil {
fmt.Println(err)
}
ただ元々見ていたGCSのライブラリなどはアクセストークンを生成していたのでIDトークンはそのままでは作ることが出来ず、ここから探索はまだ続きます。
IDトークンを生成する
とりあえず以下の2つを参考にIDトークンを取得して認証として不要して投げるコードを書きました。特に2つ目のリンクはまさにやりたいことそのもので大いに参考になりました。
上記を参考に作成したコードが以下となります。
func main() {
url := "https://target-xxx.a.run.app/healthcheck"
token := google.IdToken(url)
client := &http.Client{}
req, _ := http.NewRequest("GET", url, nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
res, _ := client.Do(req)
body, _ := io.ReadAll(res.Body)
defer res.Body.Close()
fmt.Println(string(body))
}
トークンの生成自体はこちらのモジュールに分離しています。
const AuthUrl = "https://www.googleapis.com/oauth2/v4/token"
func IdToken(targetAudience string) string {
// ADC
credentials, err := google.FindDefaultCredentials(oauth2.NoContext, AuthUrl)
if err != nil {
fmt.Println(err)
}
config, err := google.JWTConfigFromJSON(credentials.JSON)
if err != nil {
fmt.Println(err)
}
// JWS
iat := time.Now()
exp := iat.Add(time.Hour)
cs := &jws.ClaimSet{
Iss: config.Email,
Sub: config.Email,
Aud: AuthUrl,
Iat: iat.Unix(),
Exp: exp.Unix(),
}
hdr := &jws.Header{
Algorithm: "RS256",
Typ: "JWT",
KeyID: config.PrivateKeyID,
}
privateKey := ParseKey(config.PrivateKey)
// Request Google OAuth server to get Token ID
cs.PrivateClaims = map[string]interface{}{"target_audience": targetAudience}
msg, err := jws.Encode(hdr, cs, privateKey)
if err != nil {
fmt.Println(fmt.Errorf("google: could not encode JWT: %v", err))
}
f := url.Values{
"grant_type": {"urn:ietf:params:oauth:grant-type:jwt-bearer"},
"assertion": {msg},
}
res, err := http.PostForm(AuthUrl, f)
if err != nil {
fmt.Println(err)
}
body, err := io.ReadAll(res.Body)
defer res.Body.Close()
if err != nil {
fmt.Println(err)
}
type resIdToken struct {
IdToken string `json:"id_token"`
}
id := &resIdToken{}
json.Unmarshal(body, id)
return id.IdToken
}
func ParseKey(key []byte) *rsa.PrivateKey {
block, _ := pem.Decode(key)
if block != nil {
key = block.Bytes
}
parsedKey, err := x509.ParsePKCS8PrivateKey(key)
if err != nil {
parsedKey, err = x509.ParsePKCS1PrivateKey(key)
if err != nil {
return nil
}
}
parsed, ok := parsedKey.(*rsa.PrivateKey)
if !ok {
return nil
}
return parsed
}
ポイントはtarget_audience
をPrivateClaimsに渡すところとかですね。ちょっとどのように渡すかもわからず悩んでしまいました。最終的にGitHubのコードを読んで「なるほどー」と言う感じになりましたが。
実行すると以下のようにサーバの戻り値を無事表示する事が出来ました。
❯ go run main.go
{"message":"OK","date":"2021-06-18T00:00:00"}
google.golang.org/api/idtoken
さて 「やりたいこと出来たぞ!」 、と良い気分で改めて以下のドキュメントを見てみました。
GCPの外部から呼び出す というめちゃくちゃそのままな記載がありますね! 何故そこを最初に読んでないんだ。。。
というわけでIdentity-Aware Proxy サンプルコードを参考にidtoken.NewClient
を使った書き方は以下となります。
func main() {
url := "https://target-xxx.a.run.app/healthcheck"
ctx := context.Background()
client, _ := idtoken.NewClient(ctx, url)
req, _ := http.NewRequest("GET", url, nil)
res, _ := client.Do(req)
body, _ := io.ReadAll(res.Body)
defer res.Body.Close()
fmt.Println(string(body))
}
かなりすっきりしましたね! Authorization: Bearer
ヘッダーを書いていませんが自動で追加されています。実行結果は以下の通り
❯ go run main.go
{"message":"OK","date":"2021-06-18T00:00:00"}
同じ結果ですね!
まとめ
GCP Cloud Runでサービス間認証をするためのIdentity TokenをGo上でどのように取得するのか? というコードを記載しまいた。言語が変わっても基本的には同じ考え方のまま使えるはずです。
ただ、最初にドキュメントを読んだ時に寝ぼけてたのか大事な箇所を読み飛ばしてだいぶ回り道をしてしまったのは否めない。。。単純にIdentity Tokenを取るAPIは用意されてないので、だいぶぐるぐるしてしまいました。せめて途中で読みなおしていればorz まあ、結果として中身がある程度わかったのでよしとする感じですね><
それではHappy Hacking!
Discussion