IAPの向こう側にあるStrapiのAPIを叩く

先日こちらの記事で、StrapiをGAEに設置してIAPを設けることで無料で手軽に認証がかかった環境にデプロイできるようにしました。

https://qiita.com/ikea_shark_jk/items/02e64d3712f847fb5368

ただこれによって、APIを叩くときも認証を超える必要が生じるようになりました。

今回はそれを解決したいと思います。

## Strapi APIからコンテンツを取得する

まず、IAPがない環境でAPIからデータを取得します。
仮として、ArticlesというContent-Typeを作成したという前提で話を進めます。

### API Keyを発行する

`Setting` > `API Tokens` > `Create API Token`をクリック
APIKeyの名前と寿命、説明文とTokenTypeを選択したら、今回は閲覧権限さえあれば十分なのでそのまま`Save`します。
画面遷移後、APIKeyが表示されるので、忘れずにメモをする。

:::note
手が滑って画面遷移してしまったり忘れてしまった場合は、生成した鍵の編集マークをクリックして、遷移後の画面で`Regenerate`をクリックするとAPIKeyを再生成してAPIKeyを確認できる。
あくまで再生成して別のTokenを発行しているだけなので、使用中のAPIKeyの場合は注意が必要。
:::

### APIからContentsを取得する

たたく前に、適当なデータをContent Managerから登録しておきましょう。公開状態にはしなくてもよいです。

APIは、VScodeのRest Client拡張を使用して叩いてみます。
拡張機能追加後、Ctrl+Nで新しタブを開き、Ctrl+K, Mで言語設定を開き、`http`に変更します。

その後、以下を張り付けます。

```http
GET http://localhost:1337/api/articles?publicationState=preview HTTP/1.1
Authorization: Bearer <token>
```

張り付けるとエディタ上にSend Requestと表示されるのでそちらをクリック。
問題なければ、以下のような内容が表示されます

```http
HTTP/1.1 200 OK

/// 中略

{
  "data": [
    {
      "id": 2,
      "attributes": {
        "title": "hoge",
        "main": "description",
        "createdAt": "2022-12-18T09:24:53.623Z",
        "updatedAt": "2022-12-18T02:00:07.912Z",
        "publishedAt": null
      }
    }
  ],
  "meta": {
    "pagination": {
      "page": 1,
      "pageSize": 25,
      "pageCount": 1,
      "total": 1
    }
  }
}
```

これで取得は完了です。
もし記事を公開状態にしている場合は、GETのクエリ文字列を削除してください。

## IAPを突破する

まず、試しにURLをIAPの向こう側のものに変更してみましょう。

```http
GET https://strapi.hogehoge.dev/api/articles?publicationState=preview HTTP/1.1
Authorization: Bearer <token>
```

返信はこうです。

```http
HTTP/1.1 401 Unauthorized
X-Goog-IAP-Generated-Response: true
Date: Sun, 18 Dec 2022 11:06:42 GMT
Content-Type: text/html
Server: Google Frontend
Content-Length: 44
Alt-Svc: h3=":443"; ma=2592000,h3-29=":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"
Connection: close

Invalid IAP credentials: Unable to parse JWT
```

401エラー、想定通り認証が突破できていないことがわかります。

これを突破するには、認証情報をヘッダーに持たせる必要があります。
まず、IAP Tokenを発行してみましょう。

`gcloud auth print-identity-token`でうまいことできないか試行錯誤していたのですが、うまくいかなかったので公式ドキュメントに掲載されているサービス アカウント キーファイルを利用した方法でTokenを取得します。

### キーファイルを作成する

```
gcloud iam service-accounts keys create <生成するファイルのファイルパス> --iam-account=<サービスアカウント名>
```

サービスアカウントは、新規に作成するのがベストだとは思いますが、面倒であればGAEのデフォルトサービスアカウントでも問題はないです。

次に、そのサービスアカウントにIAPの`IAP-secured Web App User`権限を付与します。

その後、適当な名前のシェルスクリプトファイルを作成し、以下をコピペします。
なお、内容は公式ドキュメントの最後の方を少し書き換えただけです。

```bash
  #!/usr/bin/env bash
  set -euo pipefail

  get_token() {
    # Get the bearer token in exchange for the service account credentials.
    local service_account_key_file_path="${1}"
    local iap_client_id="${2}"

    local iam_scope="https://www.googleapis.com/auth/iam"
    local oauth_token_uri="https://www.googleapis.com/oauth2/v4/token"

    local private_key_id="$(cat "${service_account_key_file_path}" | jq -r '.private_key_id')"
    local client_email="$(cat "${service_account_key_file_path}" | jq -r '.client_email')"
    local private_key="$(cat "${service_account_key_file_path}" | jq -r '.private_key')"
    local issued_at="$(date +%s)"
    local expires_at="$((issued_at + 3600))"
    local header="{'alg':'RS256','typ':'JWT','kid':'${private_key_id}'}"
    local header_base64="$(echo "${header}" | base64)"
    local payload="{'iss':'${client_email}','aud':'${oauth_token_uri}','exp':${expires_at},'iat':${issued_at},'sub':'${client_email}','target_audience':'${iap_client_id}'}"
    local payload_base64="$(echo "${payload}" | base64)"
    local signature_base64="$(printf %s "${header_base64}.${payload_base64}" | openssl dgst -binary -sha256 -sign <(printf '%s\n' "${private_key}")  | base64)"
    local assertion="${header_base64}.${payload_base64}.${signature_base64}"
    local token_payload="$(curl -s \
      --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
      --data-urlencode "assertion=${assertion}" \
      https://www.googleapis.com/oauth2/v4/token)"
    local bearer_id_token="$(echo "${token_payload}" | jq -r '.id_token')"
    echo "${bearer_id_token}"
  }

  main(){
    # TODO: Replace the following variables:
    SERVICE_ACCOUNT_KEY="service_account_key_file_path"
    IAP_CLIENT_ID="iap_client_id"
    URL="application_url"

    # Obtain the Bearer ID token.
    ID_TOKEN=$(get_token "${SERVICE_ACCOUNT_KEY}" "${IAP_CLIENT_ID}")
    echo ${ID_TOKEN}
  }

  main "$@"
```

文中にある以下の項目は置き換えが必要なので置き換えましょう。

`IAP_CLIENT_ID`は、GCPの`Identity-Aware Proxy`にある`AppEngineアプリ`から`OAuth構成に移動`を選択し、右上に表示されるクライアントIDの値です。

```
    SERVICE_ACCOUNT_KEY="service_account_key_file_path"
    IAP_CLIENT_ID="iap_client_id"
    URL="application_url"
```

実行するとIAP Tokenが得られるので、それを先のRest Clientの`Authorization`ヘッダーに張り付けてみましょう。

```http
GET http://strapi.hogehoge.dev/api/articles?publicationState=preview HTTP/1.1
Authorization: Bearer <IAP Token>
```

すると、エラーの内容がこう変わります。

```http
HTTP/1.1 403 Forbidden

// 中略

{
  "data": null,
  "error": {
    "status": 403,
    "name": "ForbiddenError",
    "message": "Forbidden",
    "details": {}
  }
}
```

エラーが変わりました。
以前はIAPからのエラーですが、今度はStrapiからのエラーとなっています。
IAPの認証を突破できたわけですね。

……まあ、代わりにStrapiの認証が突破できなくなりましたが。


### IAPの認証ヘッダとStrapiの認証ヘッダを一つのリクエストに登録する。

こうなってしまったのは、IAPで最初に案内されるヘッダーとStrapiで使用されるヘッダーが被ってしまったためです。

幸いなことに、IAPはAuthorizationヘッダーだけではなく`Proxy-Authorization`ヘッダーでも有効なので、そちらを記載します。

```http
GET http://strapi.hogehoge.dev/api/articles?publicationState=preview HTTP/1.1
Proxy-Authorization: Bearer <IAP-token>
Authorization: Bearer <Strapi-token>
```

その結果がこちらです。


```http
HTTP/1.1 200 OK

//中略

{
  "data": [
    {
      "id": 2,
      "attributes": {
        "title": "hoge",
        "main": "description",
        "createdAt": "2022-12-18T18:24:53.623Z",
        "updatedAt": "2022-12-18T11:00:07.912Z",
        "publishedAt": null
      }
    }
  ],
  "meta": {
    "pagination": {
      "page": 1,
      "pageSize": 25,
      "pageCount": 1,
      "total": 1
    }
  }
}
```

ローカルで実行するのと同じ結果が返ってきていますね。
これにてIAP越しにAPIを叩くのは完了です。

今回はvscodeの拡張を使用して叩きましたが、もしnode.js等などから叩くのであれば、公式でライブラリを案内されているので、そちらに従ってください。

https://cloud.google.com/iap/docs/authentication-howto#obtaining_an_oidc_token_for_the_default_service_account