🪐

Google Cloud APIのアクセストークンをSDKを使わずに手に入れる

2024/01/01に公開

SDKを使わずにGoogle Cloud APIを使いたい。アクセストークンどうする?

以下のような状況になったとします。

  • SDKが使えない状況でGoogle Cloudのサービスを利用したい。この場合、各サービスのREST APIを呼び出すことになる。
  • REST APIを使うには、アクセストークンが必要である。
  • アクセストークンもSDKを使わずに手に入れたい。

そこで、SDKを使わずに特定のサービスアカウントに紐づくアクセストークンを手に入れる方法を考えてみます。
前提として、OAuth 2.0の認可コードフローなどのようなユーザーの認証が挟まる方法ではなく、対象のサービスアカウントのクレデンシャル(json形式の鍵ファイル)だけを使って実現できる方法を考えます。
※そのほかのアクセストークン取得方法は、記事の後ろで紹介します。
※この記事で示すサンプルコードはすべてGolangですが、ほかの言語でも同様の動きになるはずです。

RFC 7523を活用してみる

Google Cloud APIのアクセストークンは、OAuth 2.0のアクセストークンです。なので、OAuthの枠組みの中で、アクセストークンを取る、というアプローチなら、SDKはなくても、アクセストークンを取得できそうです。Google のOAuth 2.0のトークンのエンドポイントは、https://oauth2.googleapis.com/tokenなので、ここになんらかの情報をPOSTして、アクセストークンを取得することができそうです。

さて、RFC 7523 というRFCがあります。この仕様には、This specification defines the use of a JSON Web Token (JWT) Bearer Token as a means for requesting an OAuth 2.0 access token as well as for client authentication. と書かれています。つまり、JSON Web Tokenを使って、OAuth 2.0のアクセストークンを取得する仕様、ということです。この仕様については、以下の記事に詳細に説明されています。

https://qiita.com/TakahikoKawasaki/items/23dcf6e99f8a03b6614d

簡単に言えば、tokenのエンドポイントにJSON Web Token(JWT)を渡すことで、アクセストークンを取得できます。では、JWTはどのように手に入れればいいのか。2つのやり方を試してみます。

自力でJWTを作る

json形式の鍵ファイルの中身に、秘密鍵があります。これを使って、自力で、JWTを作る方法を試してみます。

package main

import (
	"crypto/x509"
	"encoding/pem"
	"fmt"
	"io/ioutil"
	"net/http"
	"net/url"
	"time"

	"github.com/dgrijalva/jwt-go"
)

var privateKey string = `-----BEGIN PRIVATE KEY-----
...(中略)...
-----END PRIVATE KEY-----
`

func main() {

	block, _ := pem.Decode([]byte(privateKey))
	if block == nil {
		panic("failed to parse PEM block containing the key")
	}

	// RSA秘密鍵のパース
	privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
	if err != nil {
		panic(err)
	}

	tokenURL := "https://oauth2.googleapis.com/token"

	// JWTの作成
	token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
		"iss":   "test@hoge.iam.gserviceaccount.com",
		"sub":   "test@hoge.iam.gserviceaccount.com",
		"aud":   tokenURL,
		"iat":   time.Now().Unix(),
		"exp":   time.Now().Add(time.Hour).Unix(),
		"scope": "https://www.googleapis.com/auth/cloud-platform",
	})

	// サービスアカウントのプライベートキーで署名
	signedToken, err := token.SignedString(privateKey)
	if err != nil {
		panic(err)
	}

	// アクセストークンの取得
	values := url.Values{
		"grant_type": {"urn:ietf:params:oauth:grant-type:jwt-bearer"},
		"assertion":  {signedToken},
		"scope":      {"https://www.googleapis.com/auth/cloud-platform"},
	}

	resp, err := http.PostForm(tokenURL, values)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()

	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		panic(err)
	}

	fmt.Println(string(body))
}

var privateKey stringは、鍵ファイルの中のprivate_keyフィールドの値です。pem形式の秘密鍵です。実際には、コードにべた書きするのは現実的ではないので、鍵ファイルをそのまま、構造体にjson.Unmarshalするのがよいと思います。
github.com/dgrijalva/jwt-goというライブラリを使って、JWTを生成します。JWT自体はオープンな仕様なので、ライブラリを使わなくても作成できますが、ここでは簡便化(自力でやると署名が面倒)のため、ライブラリを使います。
トークンURL(https://oauth2.googleapis.com/token)にポストする内容は、上掲したQittaの記事などを参考にし、Content-Type: application/x-www-form-urlencodedで以下の内容を含めました。

  • grant_type ... urn:ietf:params:oauth:grant-type:jwt-bearer
  • assertion ... 作成したJWT
  • scope ... https://www.googleapis.com/auth/cloud-platform

このコードを実行してみると、以下の通りアクセストークンが取得できました。

{"access_token":"xxxx...(略)...abc_x5","expires_in":3599,"token_type":"Bearer"}

以下のエンドポイントを使うと、トークンの情報が確認できますので、これで確認してみると、正当な結果がかえってきました。

curl https://oauth2.googleapis.com/tokeninfo?access_token=xxxx....

あとは、実際にこのアクセストークンが使えるのか、確認したいです。前回書いた記事でvbsからCloud Tasksを使おうとしました。上で作成したアクセストークンをこのvbsに設定してキューにタスクが突っ込めるか確認してみたところ、無事、エンキューできました。

gcloudコマンドで取得したIDトークンでアクセストークンを手に入れようとしてみる

Google Cloudは、ユーザー/サービスアカウントのIDトークンを提供します。IDトークンは、JWTなので、わざわざ、JWTを自力でつくらなくても、Google Cloudの提供するIDトークンで代替できないか、と考えるのは自然です。
が、結論からいうとうまくいきませんでした。
Google Cloudの認証が通っている環境で、以下のコマンドを実行すれば、その認証済みのユーザー/サービスアカウントのIDトークンを取得できます。

gcloud auth print-identity-token --audiences="https://oauth2.googleapis.com/token"

アクセストークン取得で使うので、--audiencesにトークン取得のエンドポイントを設定しています。

では、これで取得したIDトークンで、以下のコードで、SDKを使わずに、アクセストークンを取得してみます。

package main

import (
	"fmt"
	"io/ioutil"
	"net/http"
	"net/url"
)

func main() {

	// アクセストークンの取得
	values := url.Values{
		"grant_type": {"urn:ietf:params:oauth:grant-type:jwt-bearer"},
		"assertion":  {"<ここにgcloudで取得したIDトークンを設定>"},
		"scope":      {"https://www.googleapis.com/auth/cloud-platform"},
	}

	resp, err := http.PostForm("https://oauth2.googleapis.com/token", values)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()

	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		panic(err)
	}

	fmt.Println(string(body))
}

しかし、結果は以下の通りでエラーになりアクセストークンは取得できませんでした。

{
  "error": "invalid_client",
  "error_description": "The OAuth client was not found."
}

まとめ

  • RFC 7523に従って、アクセストークンを取得することができる。
    • この場合、Google CloudのSDKは、不要。
    • JSON Web Tokenが作成できる処理系であれば、SDKが提供されていなくても、アクセストークンが取得できるので、Google Cloud APIをREST APIで活用できる。

[補足] そのほかのアクセストークンの取得方法

前回書いた記事では、gcloudコマンドを使ってアクセストークンを取得しましたが、そのほかにもいくつか取得方法があります。

SDKを使ってアクセストークンを取ってみる

Google Cloudの各サービスのSDKを使った場合、そのSDKが内部的に認証/認可の処理を行っているので、SDKがあるなら、わざわざ、アクセストークンを取得する必要はありません。ここでは、参考までに、SDKを使ってアクセストークンだけ取得する方法をGolangで示します。

package main

import (
	"context"
	"fmt"
	"log"

	credentials "cloud.google.com/go/iam/credentials/apiv1"
	credentialspb "google.golang.org/genproto/googleapis/iam/credentials/v1"
)

func main() {

	ctx := context.Background()
	c, err := credentials.NewIamCredentialsClient(ctx)
	if err != nil {
		log.Fatal(err)
	}
	defer c.Close()

	req := &credentialspb.GenerateAccessTokenRequest{
		Name: "projects/-/serviceAccounts/test@hoge.iam.gserviceaccount.com",
		Scope: []string{
			"https://www.googleapis.com/auth/cloud-platform",
		},
	}
	resp, err := c.GenerateAccessToken(ctx, req)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(resp.GetAccessToken())
}

デフォルト認証で、なんらかのユーザー/サービスアカウントで認証が通っているという前提です。また、そのユーザー/サービスアカウントは、サービス アカウント トークン作成者(roles/iam.serviceAccountTokenCreator) を持っている必要があります。

GenerateAccessTokenRequestですが、

  • Name ... これには、取得したいアクセストークンが紐づくサービスアカウントを指定します。projects/-/serviceAccounts/<メールアドレス> という形式です。
  • Scope ... Google APIのOAuth 2.0 スコープ から、自分が使いたいAPIのスコープを選びます。今回は、Cloud TaskのREST APIを使ってみたいので、https://www.googleapis.com/auth/cloud-platformを設定しています。

適切に設定が行われていれば、このコードを実行すると、アクセストークンが取得できることがわかります。

GolangのSDKで示しましたが、もちろん、ほかの言語でもあります。google cloud iam credentials <言語名> でGoogle検索してみれば、見つかると思います。

Cloud Runからメタデータサーバにアクセスしてアクセストークンを取る

Cloud Runで動いているウェブアプリから、メタデータサーバへGETのリクエストを飛ばすだけで、アクセストークンを取ることができます。この場合、このCloud Runの実行アカウントのアクセストークンが手に入ります。この方法なら、SDKを使えない環境でも、Httpリクエストを使って、Cloud Runからアクセストークンを手に入れることができそうです。

package main

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
)

func main() {
	http.HandleFunc("/", handle)
	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
		log.Printf("Defaulting to port %s", port)
	}

	log.Printf("Listening on port %s", port)
	if err := http.ListenAndServe(fmt.Sprintf(":%s", port), nil); err != nil {
		log.Fatal(err)
	}
}

func handle(w http.ResponseWriter, r *http.Request) {
	token, err := fetchMetadataToken()
	if err != nil {
		http.Error(w, "Error fetching access token", http.StatusInternalServerError)
		return
	}

	fmt.Fprintln(w, "Access Token:", token)
}

func fetchMetadataToken() (string, error) {
	client := &http.Client{}
	req, err := http.NewRequest("GET", "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token", nil)
	if err != nil {
		return "", err
	}

	req.Header.Add("Metadata-Flavor", "Google")

	resp, err := client.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", err
	}

	return string(body), nil
}

同様の方法でIDトークンも取得できます。が、前掲したgcloudコマンドで取得したIDトークン同様、アクセストークン取得には利用できませんでした。

Discussion