GCPの非同期プロダクトからのリクエストを認証する

こちらはVISITS advent calendar 14日目の記事です。

GCPのいくつかのプロダクトでは、処理の後に予め登録しておいたエンドポイントをHTTPで呼び出すことができます。

時限的に処理を開始したい場合や何かのイベントの後に特定の処理を行いたい場合などに、プロダクトと独立させて処理を実行できるため色々と融通が効きます。

受ける側もHTTPさえ受けられれば通常のWebアプリケーションでも問題ないので便利なのですが、リクエストを送ってきた相手が本当にGCPのプロダクトなのか確認する必要があります。

これについては、2019年4月頃よりサービスアカウントを用いたトークン認証(OAuth, OIDC)ができるようになったようです。

いくつか上記の認証を紹介する記事はあったのですが、具体的な認証の実装をしているものがあまり見当たらなかったので今回rubyで書いてみることにしました。

執筆にあたっては以下の記事を参考にさせていただきました。
この記事だけ読んで一通り設定できるようにしたいため、いくつか内容が重複するところあるかと思いますがご容赦ください。

- [GCP からの HTTP リクエストをセキュアに認証する](https://medium.com/google-cloud-jp/gcp-%E3%81%8B%E3%82%89%E3%81%AE-http-%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88%E3%82%92%E3%82%BB%E3%82%AD%E3%83%A5%E3%82%A2%E3%81%AB%E8%AA%8D%E8%A8%BC%E3%81%99%E3%82%8B-dda4933afcd6)
- [Automatic OIDC: Using Cloud Scheduler, Tasks, and PubSub to make authenticated calls to Cloud Run, Cloud Functions or your Server](https://medium.com/google-cloud/automatic-oidc-using-cloud-scheduler-tasks-and-pubsub-to-make-authenticated-calls-to-cloud-run-de9e7e9cec3f)

# GCPのHTTP認証

GCPでは以下のプロダクトについては、エンドポイントを登録しておくことで後処理をHTTPで投げられるようになっています。

- Cloud Scheduler
- Cloud Tasks
- Cloud Pub/Sub

上記のプロダクトからは以下のようなプロダクトに対して処理を投げることができます。

- Cloud Run
- Cloud Functions
- Cloud Endpoints
- その他任意のサーバー/サービス

GCPのプロダクトを組み合わせた場合、基本的に認証はGCP側でよしなにやってくれるため便利です。

またGCE/GAE/GKEといったHTTPを受けられるようなプロダクトや、GCP外の自前のサーバー等でも可能です。
ただし、この場合はエンドポイントを公開しているサーバー側で認証を対応する必要があります。

# 認証の流れ

実際に認証する際は以下のような流れになります。

1. 認証用のサービスアカウントを作成する
1. 受信側で認証する 
  1. 認証機構を持つプロダクトの場合：サービスアカウントにIAMで関連のロールを付与
  1. 自前の場合：送られてくるidトークンを認証する
1. 送信側のプロダクトにサービスアカウントを紐付ける

## 1. 認証用のサービスアカウントを作成する

まずはサービスアカウントを作成します。
予めロールを設定するプロダクトが分かっていれば、それにあったプロダクトのロールをここで設定しますが、後ほど設定も可能なので後回しでも大丈夫です。

今回は `gcp-oidc-auth@{project-id}.iam.gserviceaccount.com` のような名前にしました。

## 2. 受信側で認証する

続いて受信側を設定します。
送信側の設定をする際に受信側のendpointを指定するので、先に受信側を用意しておく必要があります。

### 2-1. 認証機構を持つプロダクトの場合

GCPプロダクトで認証できる場合は、さきほど作成したサービスアカウントに受信側プロダクトのロールを付与します。
今回は例としてCloud Runを取り上げます。

なおCloud Endpointsに関しては、[違った手順](https://cloud.google.com/endpoints/docs/openapi/service-account-authentication?hl=ja#configure_auth)で認証を構成することになります。

#### Cloud Runサービスの作成

まずはCloud Runに飛んでサービスを作成します。

![スクリーンショット 2020-12-07 23.39.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5964/f8bb752d-52a1-9ade-c114-7285c03f6615.png)

リージョンやサービス名は適当に決めて次へ。

![cloud_run_detail.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5964/9a2167c2-3dba-4eb5-bfd4-40d4d930e0b2.png)

コンテナを指定するところは、適当なイメージを選択します。

詳細設定内にあるサービスアカウントは、あくまでCloud Runが何かGCPのAPIを叩くときに使うサービスアカウントになります。
1で作成したものは送信側に設定するものなので、ここではCloud Run用（もっと言うとCloud Runのサービスごと)のサービスアカウントを割り当てた方が良いと思われます。

![cloud_run_auth.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5964/0487e00a-a27d-7629-18d1-6c4b88646e98.png)

3つ目にHTTPのトリガーを指定しますが、ここで「認証を必要とする」を選択して、Cloud Runサービスを作成します。

#### IAMの設定

最後の「認証を必要とする」では、IAMにて送信側に設定するサービスアカウントに適切なロールを付与する必要があります。
ロールは受信側プロダクトに依存したものを付与する必要があります。

- Cloud Run: `Cloud Run 起動元`
- Cloud Functions: `Cloud Functions 起動元`

![cloud_iam_with_cloud_run.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5964/5984e8ee-3bf1-865e-e7f7-8c8cf26776e3.png)

### 2-2. 自前でIDトークンを認証する

自前でIDトークンを検証する場合は、トークンの中身について把握する必要があります。

サービスアカウントを紐付けると、そのGCPプロダクトからのリクエストのAuthorizationヘッダーにBearer Tokenがjwt形式で渡ってきます。

IDトークンをdecodeするとこのような形になります。
この場合1つ目のjsonがペイロード、２つ目がヘッダーになっています。

```
[
  {
    "aud": "https://hogehoge.com/path/to/endpoint", 
    "azp": "11.................52",
    "email": "hoge-service-account@{project-id}.iam.gserviceaccount.com",
    "email_verified": true,
    "exp": 1606186661,
    "iat": 1606183061,
    "iss": "https://accounts.google.com",
    "sub": "11.................52"
  },
  {
    "alg"=>"RS256",
    "kid"=>"dedc012d07f52aedfd5f97784e1bcbe23c19724d",
    "typ"=>"JWT"
  }
]
```

ペイロードについては[公式](https://developers.google.com/identity/protocols/oauth2/openid-connect#an-id-tokens-payload)の説明がありますので、より詳しくはそちらを参照ください。

| キー | 内容 |
|:--|:--|
| aud | jwtのaudクレーム。`Cloud Scheduler`の場合デフォルトで受信側endpointのURLが入る。 |
| azp | 独自のクレーム。認証された送信者のクライアントIDを指すらしいが、OAuthにおいてwebアプリとAndroidアプリなどで同じ人なのに違うIDで管理される場合などに使うらしい。今回は対象外か。  |
| email | 独自のクレーム。サービスアカウントが入ってくる。 |
| email_verified | ユーザー認証が済んでいればtrue。おそらくOAuthで一般ユーザーが送信する場合は認証済みでないケースは想定されるが、今回のサービスアカウントの場合は基本的にtrueのはず。 |
| exp | jwtのexpクレーム。ライブラリを使えば基本期限切れのチェックはやってくれる。 |
| iat | jwtのiatクレーム。 |
| iss | jwtのissクレーム。ID tokenの場合 `https://accounts.google.com` か `accounts.google.com` のどちらかになる。 |
| sub | jwtのsubクレーム。Googleのアカウント全体でアカウントを特定できる、ユニークなasciiコード列が入るとのこと。 |

ヘッダーについては、IDトークンでは現在のところRS256が使われているようです。

kidは署名に用いられた鍵を表しており、Googleが公開している[Discovery](https://accounts.google.com/.well-known/openid-configuration
)のjwks_uriから取得できる鍵リストの中から、一致するものをdecodeに用います。
この鍵リストは定期的に変わるようなので、cacheするとしても一定期間で取り直した方が良さそうです。

#### IDトークンの検証手順

IDトークンの検証手順についても[公式](https://developers.google.com/identity/protocols/oauth2/openid-connect#validatinganidtoken)で以下の5stepで説明されています。

1. Google発行の証明書が用いられているか検証する
1. issクレームがgoogleのもの (`https://accounts.google.com` または `accounts.google.com`) か検証する
1. audクレームが送信側のプロダクトごとに設定される項目と一致するか検証する
1. expクレームが有効期限内か検証する
1. hdパラメータを設定している場合、hdクレームが正しいか検証する

この他、サービスアカウントの場合はemailクレームも想定したものか検証した方が良さそうです。

#### 実装

rubyでやる場合は `googleauth` gem(v0.13.0以降)を利用すると便利です。
自前でやる場合は証明書の管理なども面倒ですが、その辺も全部やってくれます。

endpointを指定するということでサーバーが必要になるので、今回はRailsで書きました。

Railsで認証を行う場合はControllerにおいて、
`ActionController::HttpAuthentication::Token::ControllerMethods` をincludeすると 

- `authenticate_with_http_token`(自前で例外など処理する)
- `authenticate_or_request_with_http_token`(失敗時の処理はお任せ)

などで簡単にtokenが取得できるようになります。

```ruby:app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include ActionController::HttpAuthentication::Token::ControllerMethods

  before_action :authenticate!

  private

  attr_reader :oidc_token_hash

  def authenticate!
    authenticate_or_request_with_http_token do |token, _options|
      @oidc_token_hash = Google::Auth::IDTokens.verify_oidc(token, aud: request.url)

      @oidc_token_hash['email'] == ENV.fetch('GCP_SERVICE_ACCOUNT_EMAIL') # 設定値の管理はENV以外でもOK
    rescue Google::Auth::IDTokens::VerificationError => _e
      false
    end
  end
```

aud/iss/(azp)のクレームはgem側でやってくれるため、emailを独自にチェックするだけで済みました。

例外処理ですが、verify_oidcはトークンがおかしい場合などにVerficationError、公開鍵周りの問題でKeySourceErrorを発生させます。

前者は入力側の問題なので400系(ここではfalseを返すので401になる)として返しておき、後者は公開鍵取得に失敗した等クライアント側はどうしようもケースということで500系として検知できるようにしておきました。
この辺りの例外の取り扱いは提供するサービスのポリシーに合わせてください。

今回は例だったのでhtmlを返す形を取っていますが、通常GCPからのリクエストを処理したい場合はapi的な処理が多いと思いますので、`ActionController::API`を継承しつつ`authenticate_with_http_token`で自前で処理するのも良いと思います。

## 3. 送信側のプロダクトにサービスアカウントを紐付ける

続いて作成したサービスアカウントを送信側プロダクトに紐付けます。
今回は例としてCloud Schedulerを取り上げます。

Cloud Schedulerの場合はAuthヘッダーでOIDCトークンを選択するところが重要です。(公式ドキュメントは[こちら](https://cloud.google.com/scheduler/docs/http-target-auth?hl=JA#creating_a_scheduler_job_with_authentication))

![gcp_cloud_scheduler.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5964/2d9c6105-2088-3dca-c10d-c24c8ff7a38d.png)

ターゲットはHTTPを選択肢、URLには受信側のendpointを指定します。
またAuthヘッダーではOIDCトークンを選択し、サービスアカウントには最初に作ったアカウントを指定します。

なお、ターゲットのURLが `*.googleapis.com`なGoogle APIのときは、[AuthヘッダーにOAuthトークンを使用する](https://cloud.google.com/scheduler/docs/http-target-auth?hl=JA#token)ようです。

一番下にある対象の項目は後の`aud`クレームの値になります。
空欄の場合はターゲットのURLが入ります。

後はcronでも手動でもいいので実行し、認証が成功するかを確認します。

![cloud_scheduler_job.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5964/37454139-3d2a-1515-9876-907b4ac6cb62.png)

Cloud Pub/SubやCloud TasksなどもHTTPターゲットの設定とサービスアカウントが設定できるので、同様の設定で大丈夫です。

# おわりに

ということでGCPの非同期系プロダクトからのリクエストを認証する設定の流れについてでした。

受信側にもし認証機構をもつプロダクトを割り当てられる場合はそちらを選択した方が楽ではありますが、idトークンの認証自体もそれほど複雑ではないので、ちゃんと導入してセキュアな状態を保ちたいですね。