CognitoとNext.jsでOAuth2.0の認可機能を実装

# この記事は？
Cognitoの各種リソース、APIを使った認可機能を実装したのでメモ
クライアントはNext.jsを使用する
※初めてNext.js触ったのでアホな実装してたらごめんなさい
※この記事の実装を実際のアプリでそのまま利用することは想定していません(諸々ガバガバなのでCognito + Next.jsの取っ掛かり程度でお願いします)

ちなみにOAuth2.0何もわからんという人は↓の記事を読んだあと

https://qiita.com/TakahikoKawasaki/items/e37caf50776e00e733be

↓の記事の`1.認可コードフロー`を読むのがおすすめ

https://qiita.com/TakahikoKawasaki/items/200951e5b5929f840a1f#1-%E8%AA%8D%E5%8F%AF%E3%82%B3%E3%83%BC%E3%83%89%E3%83%95%E3%83%AD%E3%83%BC

# OAuth2.0の概要
OAuth2.0による認可方法についてざっくりと説明する

:::note
OAuth2.0には`認可コードフロー(Authorization Code Grant)`、`Implicit Grant Type`、`Client Credentials Grant Type`など様々なフローが用意されている
今回紹介するのは認可コードフローのみなので悪しからず
:::

## 登場人物
まずはOAuth2.0のフローに出ててくる登場人物についてまとめる
![登場人物.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2840455/fe324242-d299-0e22-a4ec-ab06d848c876.png)
- リソース所有者
  - `保護対象リソース`へのアクセス権を持つ人
  - 要するにアプリのユーザー
- クライアント
    - `リソース所有者`に代わって実際に`保護対象リソース`へアクセスするプログラムないしソフトウェア
- 認可サーバー
    - `クライアント`に対して`保護対象リソース`へのアクセス権(アクセストークン)を発行するサーバー
- 保護対象リソース
    - `リソース所有者`がアクセス権を持っているリソース(≒Web API)
## 認可のフロー
これらの登場人物がどのように振舞うかをまとめたシーケンス図↓
![シーケンス図.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2840455/ef9564f2-9947-11a1-8094-d2b9808df666.png)

### フローの補足と用語の説明
#### 2. AuhtorizationCode発行画面へリダイレクト
リソース所有者に**このクライアントに対してAuthorizationCodeを発行**してもよいか尋ねる画面へリダイレクトする
この際、クエリパラメータとして以下の値を渡すことになる

##### client_id
認可サーバーが各クライアントに対して発行するID
クライアントは予めこのクライアントIDを発行しておき、諸々リクエストする際にはこれを使って**認可サーバーから信頼されたクライアントである**ことを示す必要がある

##### response_type
このエンドポイントでリソース所有者が承認した場合のレスポンスとして何を返してもらうか制御するパラメータ
**認可コードフローの場合は**`code`**を指定する(=AuthorizationCodeを返してもらう)**
その他のフローの場合は`token`を指定して直接アクセストークンをもらったりもする

##### redirect_uri
リソース所有者が承認した後にリダイレクトするURI
このエンドポイントへAuthorizationCodeと一緒にリダイレクトすることになるので、後述のAuthorizationCodeとアクセストークンの交換を実行するパスを指定しておこう

##### state
UUIDv4などの推測できないランダム文字列を入れる
AuhtorizationCodeと同様にredirect_uriへリダイレクトする際にこの値が返ってくるので、リクエスト時に入力した値と同じか検証することでCSRF攻撃を防ぐことができる
※今回は触れないもののstateの代わりにPKCEという仕組みを利用する場合もある(というかそっちの方がより安全)
※↓わかりやすく説明されている記事

https://qiita.com/naoya_matsuda/items/992c67b803ff460818ec

##### scope
今回は特に触れないので概要だけ
この一連の流れで取得するアクセストークンの権限の範囲を指定するパラメータ
Adminなら全部許可とか一般のユーザーなら一部だけとか細かく権限を分けたいときに使う

#### 6. stateの検証
認可サーバーからクライアントへリダイレクトしてくる際、クエリパラメータに**state**が入っている
この値が`2`で送ったものと一致しているかをここで検証する
具体的には、`2`でstateを発行する際にセッションIDも発行しそれと紐づけて適当なデータストアに保存しておき、CookieにはセッションIDだけ保存する
そして、この処理に入った時にCookieからセッションIDを取得してそれに紐づくstateとクエリパラメータのstateが一致するか検証、という流れになる

#### 7. AuhtorizationCodeとAccessTokenの交換をリクエスト
以下のパラメータを使ってリクエストする(説明済みの値は省略)

##### client_secret
**client_id**と同様に認可サーバーから発行してもらう認証情報
なお**client_id**とはクライアント(より具体的にはWebアプリのバックエンド、Next.jsでいう`/pages/api`以下のファイル)と認可サーバーのやり取りの中でしか使われず、**ユーザーの目に入ることがない**という点で異なる

:::note warn
**client_secret**はHTMLなどユーザーが閲覧可能な領域に保存しないよう注意！
:::

※ちなみに、バックエンドのサーバーを持たないアプリの場合(どうあがいてもclient_secretを秘匿できない場合)は認可コードフローではなくclient_secretを使う必要のない以外のフローを選択することになる
※当然その場合はclient_secretを発行する必要はない


##### code
`5`で受け取ったAuthorizationCodeを入れる

##### grant_type
OAuth2.0のどの認可フローを使ってアクセストークンを取得するか指定する
今回は認可コードフローなので`authorization_code`になる

##### redirect_uri
`2`で送ったのと同じURIを入れる
認可サーバーにPOSTしているのでこのURIにリダイレクトするわけではないけれど、grant_type=authorization_codeの場合は検証のためこの値が要求される


# CognitoをOAuth2.0にあてはめる
前項の認可フローをCognitoのリソースをあてはめてみる
## 登場人物
OAuth2.0の登場人物とCognitoのリソースは↓のような対応関係にある
![登場人物_Cognito.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2840455/f91cc994-699f-055d-8872-473e0bb67e91.png)

- [ユーザー](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/signing-up-users-in-your-app.html)
    - Cognito UserPoolというリソースで管理されるユーザー
- [Cognito アプリクライアント](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/user-pool-settings-client-apps.html)
    - Cognitoの認可APIとやりとりするクライアントを抽象化したもの
    - 前述の**client_id**や**client_secret**はこのアプリクライアントを作成することで発行される
- [Cognito API](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/cognito-userpools-server-contract-reference.html)
    - AuthorizationCode発行を承認する画面やAuthorizationCodeとアクセストークンの交換をするAPIなどを提供してくれる
- [API](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-integrate-with-cognito.html)
    - API GatewayのAuthorizerにCognitoを指定したAPI
    - 要するにCognitoが発行したトークンがないとアクセスできないAPIのこと

## シーケンス
Cognitoからアクセストークンをもらってくるまでの手順を整理する

まず最初に、**認可してもらうユーザー自体の作成**が必要
→Cognito UserPoolにサインアップする、いわばOAuth2.0の前段の話
その次にアクセストークンを取得し、それを使って保護されたAPIを叩くことになる

したがって、サインアップとトークン取得、API実行という大きく分けて3つのシーケンスが存在する

### サインアップシーケンス
Cognito UserPoolにユーザーを新規作成するシーケンス
![アカウント作成シーケンス.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2840455/d692ca09-300b-2667-c604-759378c29eca.png)
#### 補足
##### SecretHash
これの正体はclient_id、client_secret、ユーザー名のハッシュ値
具体的な生成方法は[こちら](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/signing-up-users-in-your-app.html#cognito-user-pools-computing-secret-hash)を参照

##### CognitoのAPIリファレンス
それぞれ以下のリンク先を参照

- [SignUp](https://docs.aws.amazon.com/ja_jp/cognito-user-identity-pools/latest/APIReference/API_SignUp.html)
- [ConfirmSignUp](https://docs.aws.amazon.com/ja_jp/cognito-user-identity-pools/latest/APIReference/API_ConfirmSignUp.html)

### トークン取得シーケンス
↑で作成したユーザーでトークンを取得しに行くシーケンス
![Cognitoトークン取得.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2840455/9653f4f0-8361-55d7-9c97-facc6117d6f1.png)

### API実行シーケンス
↑で取得したトークンを使って保護されたAPIを叩くシーケンス
![API実行.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2840455/bfbcf841-fe39-7ec5-3bee-6b50150359b2.png)

# 実装例
## 構成図
![構成図.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2840455/0d0e1288-46a2-0760-e11e-b354426646c0.png)
ローカル環境にNext.jsで作ったクライアントを動かし、AWS上のリソースとやりとりする構成

## コード
インフラ

https://github.com/damepo9696/cognito-example-infra

クライアント

https://github.com/damepo9696/cognito-example-client

## 環境構築手順
### インフラの構築
`cognito-example-infra`のディレクトリへ移動し、カスタムドメインのプレフィックスをつける([ここ](https://github.com/damepo9696/cognito-example-infra/blob/main/lib/cognito-example-stack.ts#L40))
→他のユーザーが使っているものと被らなければOK

そうしたらCDKのデプロイコマンドを叩く
```bash
npm install
cdk deploy CognitoExampleStack
```

### クライアントの構築
`cognito-example-client`のディレクトリへ移動し、以下のコマンドで環境変数を設定する

```bash
bash scripts/init-env.sh
```

これによって`.env`ファイルにAPI GatewayのエンドポイントやCognito UserPoolのIDなどが登録される

完了後、以下のコマンドでクライアントを立ち上げる

```bash
npm install
npm run dev
```

## 動作確認＆コードの説明

http://localhost:3000 にアクセスしてアカウント作成画面を開く

### アカウント作成
![スクリーンショット 2022-10-03 222020.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2840455/cc109bea-671f-e92f-9a79-011962187f25.png)

[このコンポーネント](https://github.com/damepo9696/cognito-example-client/blob/main/pages/index.tsx)がレンダリングされる
メアドとパスワード入力して`次へ`をクリックすると、[CognitoのSignUp API](https://github.com/damepo9696/cognito-example-client/blob/main/services/cognito.ts#L19)を実行してユーザーが作成される

### メールアドレスの検証
![スクリーンショット 2022-10-03 222057.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2840455/f80eefed-b995-b8a1-e583-9994f3e96099.png)

メールアドレスの検証画面へ移動する
この段階でユーザープールの画面を開くと、先ほど入力したメアドでユーザーが作成されている

![スクリーンショット 2022-10-03 222137.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2840455/5721fb06-9643-9ccd-faec-c81f9e8ac569.png)

検証コードがメールで届くのでそれを入力して`検証`ボタンをクリック
この時、[CognitoのConfirmSignUp](https://github.com/damepo9696/cognito-example-client/blob/main/services/cognito.ts#L29)を実行している

![スクリーンショット 2022-10-03 222212.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2840455/4ef5b336-afd3-1a61-d053-fe9261f29467.png)

するとユーザーの確認ステータスが`確認済み`となり、これでユーザー作成は完了

### ログイン(トークン取得)
![スクリーンショット 2022-10-03 222221.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2840455/4a29b879-7544-40f3-11d7-c54541f83989.png)

ログイン画面へ移動するので`ログイン画面へ`ボタンをクリック
この時の処理がちょっと複雑なので順を追って説明

1. セッションIDとstateを生成
2. DynamoDBにセッションIDとstateを、CookieにセッションIDのみを保存
3. [stateなどでAuthorizeURIを生成](https://github.com/damepo9696/cognito-example-client/blob/main/services/cognito.ts#L45)
4. AuthorizeURIへリダイレクト

するとCognitoの画面へ移動するのでメアド、パスワードを入力する
そうしたら再びクライアントへ返ってきて、[このファイル](https://github.com/damepo9696/cognito-example-client/blob/main/pages/api/signin/callback.ts)の処理が走る

```/pages/api/signin/callback.ts
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  try {
    const authorizationCode = req.query.code as string;
    const state = req.query.state;
    const sessionId = req.cookies.session || "";
    const session = await sessionsTableService.get(sessionId);
    if (!state || session.state !== state) {
      throw new Error("Validate state failed");
    }
    const tokens = await cognitoService.getTokenWithAuthorizationCode(
      authorizationCode
    );
    await sessionsTableService.create({
      id: sessionId,
      state: "",
      idToken: tokens.idToken,
      expireIn: tokens.expireIn,
    });
    res.redirect(Path.Test);
  } catch (error) {
    console.error(error);
    res.status(400).json({ message: "Signin callback failed" });
  }
}
```

処理の流れは、
1. クエリパラメータから`AuthorizationCode`と`state`を取得する
2. CookieからsessionIDを取得し、それでDynamoDBから`state`を取得する
3. `state`が一致するか確認する
4. Cognitoのトークンエンドポイントにリクエストする
5. トークンをDynamoDBに保存する


### 保護対象リソースへアクセス

![スクリーンショット 2022-10-03 222251.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2840455/6f2cbac7-497e-ce6b-2bef-a5c9d9f1aec5.png)

この画面で`APIを実行`ボタンをクリックすると、Cognitoによって保護されたAPI Gatewayへアクセスできる

この時の処理の流れが↓([ファイルはこれ](https://github.com/damepo9696/cognito-example-client/blob/main/pages/api/test/index.ts))

1. CookieからsessionID取得
2. DynamoDBからIDトークン取得
3. リクエストヘッダーにIDトークンをセットしてリクエスト