AWS Lambda から自社 API サーバーへのリクエストに Amazon Cognito による認証機構を実装する

AWS Lambda (以降、Lambda) から自社 API サーバー (以降、API サーバー) へのリクエストに Amazon Cognito (以降、Cognito) による認証を実装した際の話をまとめました。
Cognito の OAuth 2.0 サービスのうち、Client Credentials Grant を使用することで Lambda を認証できるようにしています。

これは M2M (Machine to Machine) 認証と呼ばれるものを Cognito で実装した例です。
Cognito を使用する例については情報が少ない気がしたので、ワード検索時の一助になればと思い記事にしました。

## この実装に至った背景
今回の最終的な要件は、Lambda から API サーバーへのリクエストに Cognito による M2M 認証機構を実装することです。

上記要件に至った背景を記載します。
長めなので具体的な実装だけ知りたい方は [実装内容](#実装内容) までスクロールしてください。

### リリースフローの自動化を推進
現在弊社では開発及び運用で必要になる作業の自動化を推進しており、今回システムのリリースフローの自動化に着手しました。

弊社では AWS を利用しているため、リリースフロー全体のコントロールには AWS サービス群と相性のよい AWS Step Functions (以降、Step Functions) を採用しました。

余談ですが、Step Functions はワークフローを視覚的に構築することができて便利です。
今後も様々なシーンで利用することになりそうな気がするので、Step Functions の活用についても今後記事にしたいと思います。

### 一括更新処理は誰がやるか
弊社リリースフローでは、データベースの特定のデータに対して、1 件ずつ更新処理を行うようなプロセスがあります。
この一括更新処理はリリースフローでのみ必要な処理のため、システム本体とは異なるプロセスから実行した方がよいだろうと考えました。

このプロセスは Lambda に担当させます。
Lambda を採用した主な理由は以下の 3 点です。

* サーバーレスで実行環境を提供してくれるので、構築コストが低い
* 既にいくつかの Lambda 関数が稼働しており、社内に構築・運用の知見がある
* Step Functions から呼び出して扱いやすく、[Map state で並列化](https://docs.aws.amazon.com/ja_jp/step-functions/latest/dg/tutorial-creating-map-state-machine.html) もしやすい

特に、今回対象としている一括更新処理の対象データは、今後もシステムの運用に伴い増え続けることが予想され、並列化して高速に処理したいと考えていました。

### Lambda はリクエストを送るだけ
今回、リリースフローの中で一括更新処理プロセスの実行を担当するのは Lambda ですが、一括更新処理そのものは API サーバー側で担当するように役割を分けて併用する形をとりました。
つまり、Lambda の役割は API サーバーのエンドポイントにリクエストを送ることだけです。

このような構成を採用したのは、以下のようなメリット・デメリットの比較検討の結果です。
特に、弊社では O/R マッパーを利用してデータベースのスキーマを管理しており、それを API サーバーで一元管理できることは大きなメリットでした。

**Lambda で処理本体を直接実行する場合**

* メリット
  - 今回の開発範囲が Lambda のみで済む
  - 一括更新処理の負荷が API サーバーにかからない
  - 一括更新処理を AWS ネットワーク内部で完結可能 (外部に向けたアクセスが不要) 
* デメリット
  - データベースのスキーマを Lambda で管理する仕組みが必要
  - データベース (Amazon RDS) への接続管理のため、Amazon RDS Proxy の導入が必要
  - 今後のシステム側ロジックの変更に合わせて、都度 Lambda のロジックも修正が必要

**API サーバーが処理本体を担当する場合**

* メリット
  - 既存のスキーマ管理方法を変更なく利用可能
  - API サーバーのみがデータベースにアクセスする構成が可能
  - システムのロジック変更時に、Lambda 側の修正が不要
* デメリット
  - API サーバーにも追加の開発が必要
  - 一括更新処理の負荷が API サーバーにかかる
  - API サーバーは外部公開されているため、追加の認証機構の実装が必要

### 認証は Cognito に任せる
Lambda 用の API エンドポイントを作成するにあたり、Lambda からの API リクエストは Cognito で M2M 認証するようにしました。

前提として、API サーバーはアプリケーションからのリクエストを受け付けるために外部公開されており、今回追加する Lambda 用のエンドポイントも外部からアクセス可能な状態となります。
エンドポイント自体は非公開としますが、不正にアクセスされた場合に拒否できる仕組みが必要でした。

Auth0 などの各種認証サービスでの実装も検討しましたが、今回は Cognito を採用しました。
Cognito を採用した一番の理由は、Lambda 同様、社内に構築・運用の知見があったことです。

また、ユーザー認証とは異なる方法で、Lambda を認証できるということも重要でした。
Cognito のユーザープールに擬似的な「Lambda ユーザー」を作成する以外に方法がなかったとしたら、Cognito を採用していなかったと思います。

M2M 認証には Cognito が提供する OAuth 2.0 サービスを利用しました。
OAuth 2.0 の [Client Credentials Grant](https://www.rfc-editor.org/rfc/rfc6749#section-1.3.4) を利用することで、Lambda を認証することができます。

※ この記事では M2M 認証と記載していますが、OAuth 2.0 で提供されているのは「認可」の仕組みです。Client Credentials Grant では、クライアントアプリケーションに対して事前に発行された ID と Secret が、秘匿されていることを前提に認可を行うことで、認証のような動作を実現できます。

## 実装内容

### 全体の構成

今回最終的に実装した認証機構の全体図は以下の通りです。

![全体図](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2920413/e600d5e7-a5e8-9508-73a8-c190de2aef83.png)

① Lambda が Cognito へアクセストークンの発行をリクエストします。
② Cognito がアクセストークンを発行して Lambda へレスポンスします。
③ Lambda がアクセストークンを保存します。
④ Lambda が API サーバーへアクセストークンを付加して API リクエストします。
⑤ API サーバーが Cognito へユーザープールの公開鍵をリクエストします。
⑥ Cognito が ユーザープールの公開鍵をレスポンスします。
⑦ API サーバーが公開鍵を使ってアクセストークンを検証します。

アクセストークンの検証結果に問題がなければ、
⑧ API サーバーが処理を実行し、結果を Lambda にレスポンスします。

※⑤、⑥ は都度実行せず事前に実行しておいて、公開鍵を API サーバー上に保管しておくという実装でもよいと思います。

以降で、具体的な実装内容を簡単に説明します。

### 準備

**Lambda の作成**

Lambda 関数を作成します。

作成する際のパラメータなどの詳細はこの記事では省略します。
お好きなランタイムでコードが動けば問題ないと思います。

今回は、`Node.js` をランタイムとして使用しました。

Lambda 作成の詳細はこの記事では解説しませんので、[AWS 公式ドキュメント](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/welcome.html) やその他の解説記事を参考にしてください。

**Cognito ユーザープールの作成 (必要に応じて)**

Amazon Cognito の OAuth 2.0 サービスはユーザープール上で提供されているため、ユーザープールの作成が必要です。

作成済みのユーザープールがある場合、これ以降の手順で実施するドメイン設定等の変更を行なっても問題ない環境であれば、既存のものを使用しても OK です。

ユーザープールの詳細はこの記事では解説しませんので、[AWS 公式ドキュメント](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/what-is-amazon-cognito.html) やその他の解説記事を参考にしてください。

**ユーザープールの設定ポイント**

ユーザープールの最低限の設定ポイントは以下の通りです。
なお、詳細はセキュリティ等も考慮しつつ環境に合わせて設定してください。

* ドメインの設定
  - OAuth 2.0 サービスエンドポイントに利用するドメイン設定が必要
  - 独自ドメインを設定することも可能
* リソースサーバの作成
  - 認証する側のサーバーを指定 (この記事では API サーバー) 
  - カスタムスコープを作成 (下記アプリケーションクライアントの作成に必要) 
* アプリケーションクライアントの作成
  - 「秘密クライアント」を選択
  - クライアントシークレットを生成
  - ホストされた UI の項目で、OAuth 2.0 許可タイプに「クライアント認証情報」を選択

上記ポイントを押さえつつ、アプリケーションクライアントの作成が完了したら、`クライアント ID` と `クライアントのシークレット` を取得しておいてください。

なお、これら二つがアプリケーションクライアントの認証情報となるため、特に `クライアントのシークレット` は絶対に外部に漏洩しないように管理してください。


### アクセストークンをリクエストする
全体図の ①〜④ の部分をコード例とともに説明します。

![全体図-part1![CognitoM2MAuthFig-part2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2920413/b51ea30f-3c46-8266-0259-29f90562b8d7.png)
](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2920413/006b0019-3bcc-75c8-728f-28fd5ed2d8d1.png)

**① Lambda が Cognito へアクセストークンの発行をリクエストします。**

Lambda 上で実行する Node.js のコード例を記載します。
```js
const axios = require('axios');
const { Buffer } = require('buffer');

const clientId = 'クライアント ID';
const clientSecret = 'クライアントのシークレット';
const authorization = `Basic ${ Buffer.from(`${ clientId }:${ clientSecret }`).toString('base64') }`;
const url = `https://Cognito ドメイン/oauth2/token`;

exports.handler = async (event) => {
  const response = await axios({
    url,
    method: 'POST',
    headers: {
      authorization,
      'content-type': 'application/x-www-form-urlencoded',
    },
    data: {
      grant_type: 'client_credentials',
    },
  });

  console.log(response.data);
};
```

アクセストークンは Cognito の [トークンエンドポイント](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/token-endpoint.html) (`/oauth2/token`) に HTTP POST リクエストを送ることで発行されます。

トークンエンドポイントの URL には、ユーザープールのドメイン設定に設定したドメインが利用されます。

HTTP リクエスト時のポイントは、以下の通りです。
* `authorization` ヘッダー
  - `クライアント ID` と `クライアントのシークレット` を `:` で繋いで `base64` でエンコード
  - `base64` でエンコードした文字列と `Basic` の文字列とを結合して指定
* `content-type` ヘッダー
  - `application/x-www-form-urlencoded` を指定
* `data` 部
  - `grant_type: 'client_credentials'` を指定

**② Cognito がアクセストークンを発行して Lambda へレスポンスします。**

①のコードの `console.log(response.data)` の部分で、以下のようなレスポンスが表示されれば成功です。

```js
{
    access_token: "*****************************************",
    expires_in: ****,
    token_type: "Bearer",
}
```

**③ Lambda がアクセストークンを保存します。**

② で Cognito からレスポンスされた `access_token` を Lambda で保存します。

Lambda はステートレスなので、 (並列処理中などに) 一定期間使い回すのであれば、AWS Systems Manager Parameter Store などのセキュアな場所に保存してください。

**④ Lambda が API サーバーへアクセストークンを付加して API リクエストします。**

API サーバーへのリクエスト時は、③ で保存したアクセストークンを `authorization` ヘッダーに指定します。

Lambda 上で実行する Node.js のコード例を記載します。
```js
const axios = require('axios');
const { Buffer } = require('buffer');

const accessToken = '③ で保存したアクセストークン';
const authorization = `Bearer ${ accessToken }`;
const url = 'API サーバーのエンドポイント';

exports.handler = async (event) => {
  const response = await axios({
    url,
    method: 'GET', // API サーバーのエンドポイントのメソッドに応じて変更してください。
    headers: {
      authorization,
    },
  });
};
```

### アクセストークンを検証する
全体図の ⑤〜⑧ の部分について説明します。

![全体図-part2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2920413/868f45f5-5645-8207-1602-8f5b936f9c98.png)

④ でLambda からの API リクエストを受け取った API サーバーは、アクセストークンを検証することで、正規のアクセスかどうかを判断します。

今回取り扱っているアクセストークンは、[JWT (JSON Web Token)](https://jwt.io/) と呼ばれる形式のトークンです。

このアクセストークン (JWT) の検証については、手順が [AWS 公式ドキュメント](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html) に記載されているので、こちらを参照して実装してください。

**⑤ API サーバーが Cognito へユーザープールの公開鍵をリクエストします。**

上記 [AWS 公式ドキュメント](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html) にも記載されている通り、アクセストークン (JWT) の検証には公開鍵である JWK (JSON Web Key) が必要です。
JWK はユーザープールごとに生成されており、上記手順にも記載の以下のような URL から取得することができます。

> https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json

**⑥ Cognito が ユーザープールの公開鍵をレスポンスします。**

⑤ のリクエストにより Cognito ユーザープールの公開鍵を取得したら、⑦ で検証します。

**⑦ API サーバーが公開鍵を使ってアクセストークンを検証します。**

前述の通り、検証手順は [AWS 公式ドキュメント](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html) に詳細が記載されていますので、詳細はこちらをご参照ください。

手順に従ってアクセストークン (JWT) を分解、検証し、最後にクレームを検証します。

⑦ の検証で問題が発生した場合、API サーバーは処理を中断してリクエストを拒否します。

**⑧ API サーバーが処理を実行し、結果を Lambda にレスポンスします。**

⑦ の検証に問題がなかった場合、API サーバーは Lambda からリクエストされた処理を実行し、その結果を Lambda にレスポンスします。

## まとめ

この記事では、前半で Lambda と API サーバー間の認証が必要だった背景をまとめました。
後半では Cognito を利用した Lambda と API サーバー間の認証プロセスの全体図と実装例をまとめました。

認証機構には Cognito の OAuth 2.0 サービス (Client Credentials Grant) を利用し、ユーザー認証ではなく、M2M 認証を実装することができました。

API サーバー等の既存のリソースを活用しつつ、リリースフローを構築することができてよかったです。認証フローの処理もあまり複雑ではないので、類似の課題を抱えた方の参考になれば幸いです。

最後に、記事中の例は詳細や考慮事項等を省略して記載している点はご注意ください。

## 参考にした記事
[AWS blog: Understanding Amazon Cognito user pool OAuth 2.0 grants](https://aws.amazon.com/jp/blogs/mobile/understanding-amazon-cognito-user-pool-oauth-2-0-grants/)

[Qiita: AWS CognitoでClient Credentials Grantを使ってみる](https://qiita.com/poruruba/items/3681f1e1f8e72fcaadfa)

[Server to Server Auth with Amazon Cognito](https://lobster1234.github.io/2018/05/31/server-to-server-auth-with-amazon-cognito/)

[Auth0 blog by okta: 機器間 (M2M) 認証を使用する](https://auth0.com/blog/jp-using-m2m-authorization/)