Cognito UserPool Oauth2.0 AuthorizationCodeGrantで認可認証する

# この記事について
Cognito UserPoolで認可認証するWEBアプリをいくつか作成してきたので大まかなフローについてまとめる
全機能を網羅するのはさすがに難しいので以下に絞って書いていく

- UserPoolへのサインアップ
  - サーバーサイドで認証するフロー(`ADMIN_USER_PASSWORD_AUTH`)についてまとめる
  - MFAデバイス登録については触れない
  - Lambdaを使って認証フローをカスタマイズする機能についても触れない
- トークン取得
  - OAuth2.0 `AuthorizationCodeGrant`によるアクセストークン等の取得方法についてまとめる
  - OIDCやUserPoolのサインインAPIによるトークン取得方法については触れない
  - また、IDプールによるユーザー毎の権限管理等についても触れない

# サインアップ
## フローの確認
超ざっくりとしたフローはこんな感じになる↓※クライアント＝フロントエンド＋バックエンド
![Untitled Diagram.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2533663/f6e44eca-3c72-ce2f-b366-48a7e5f297d3.png)

そしてこのフローを実際に[Cognito UserPool API](https://docs.aws.amazon.com/ja_jp/cognito-user-identity-pools/latest/APIReference/API_Operations.html)を使って実装する場合のシーケンス図が↓※クライアントとCognitoの間にCognitoを操作するためのAPIサーバーをかませている

```plantuml
@startuml sequence

title サインアップシーケンス

participant クライアント
participant 認可認証サーバー
participant Cognito

クライアント -> 認可認証サーバー: 1. POST /auth/users
note right クライアント
{
  "email": "{EMAIL}",
  "password": "{PASSWORD}",
  "client_id": "{CLIENT_ID}",
  "client_secret": "{CLIENT_SECRET}"
}
end note

認可認証サーバー -> Cognito: 2. SignUp
note right 認可認証サーバー
{
  "ClientId": "{CLIENT_ID}",
  "Username": "{EMAIL}",
  "Password": "{PASSWORD}",
  "SecretHash": "{SECRET_HASH}"
}
end note

Cognito --> 認可認証サーバー: 3.
認可認証サーバー --> クライアント: 4.
note right クライアント #White
※サインアップ確認コードがメールで届く
end note

クライアント -> 認可認証サーバー: 5. POST /auth/users/confirm
note right クライアント
{
  "email": "{EMAIL}",
  "confirmation_code": "{CODE}",
  "client_id": "{CLIENT_ID}",
  "client_secret": "{CLIENT_SECRET}"
}
end note

認可認証サーバー -> Cognito: 6. ConfirmSignUp
note right 認可認証サーバー
{
  "ClientId": "{CLIENT_ID}",
  "Username": "{EMAIL}",
  "ConfirmationCode": "{CODE}",
  "SecretHash": "{SECRET_HASH}"
}
end note

Cognito --> 認可認証サーバー: 7.
認可認証サーバー --> クライアント: 8.

@enduml
```

## インフラ構成図
シーケンス図の登場人物をインフラ構成図に書き出した
![Untitled Diagram (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2533663/492cd159-e4c4-1e99-176f-f00468ba4f15.png)
※クライアントはDockerでコンテナ化してECSにデプロイした方がいいけど今回は横着してローカルに建てる


## インフラの実装
ディレクトリ構成

```
hoge/
  ├─ client/
  └─ infra/
```

### インフラ
[CDKv2](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-construct-library.html)でインフラをコード化する
※スタック名とか適当なので適宜変更してもOK

CDKのインストール

```bash
npm i -g aws-cdk
cdk --version
```

プロジェクト初期化

```bash
cd infra
cdk init sample-app --language typescript
cdk bootstrap
```

スタックの編集

```/infra/lib/infra-stack.ts
import { RemovalPolicy, Stack, StackProps } from "aws-cdk-lib";
import * as cognito from "aws-cdk-lib/aws-cognito";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as logs from "aws-cdk-lib/aws-logs";
import * as apigateway from "aws-cdk-lib/aws-apigateway";
import { Construct } from "constructs";

const CALLBACK_URL = "http://localhost:3000/auth/authorize/callback";
const DOMAIN_PREFIX = "hoge"; // ※1

export class InfraStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // UserPool
    const userPool = new cognito.UserPool(this, "UserPool", {
      email: cognito.UserPoolEmail.withCognito(), // ※2
      passwordPolicy: {
        minLength: 8,
        requireDigits: false,
        requireLowercase: false,
        requireUppercase: false,
        requireSymbols: false,
      },
      selfSignUpEnabled: true,
      signInAliases: {
        email: true,
      },
      userPoolName: "cognito-test-user-pool",
      removalPolicy: RemovalPolicy.DESTROY,
    });

    // UserPoolのアプリクライアント
    const userPoolClient = new cognito.UserPoolClient(this, "UserPoolClient", {
      userPool: userPool,
      disableOAuth: false,
      generateSecret: true, // ※3
      oAuth: {
        flows: { authorizationCodeGrant: true },
        callbackUrls: [CALLBACK_URL],
      },
      supportedIdentityProviders: [
        cognito.UserPoolClientIdentityProvider.COGNITO,
      ],
      authFlows: {
        adminUserPassword: true,
      },
      userPoolClientName: "cognito-test-user-pool-client",
    });

    // UserPoolのドメイン
    const userPoolDomain = new cognito.UserPoolDomain(this, "UserPoolDomain", {
      userPool: userPool,
      cognitoDomain: {
        domainPrefix: DOMAIN_PREFIX,
      },
    });

    // 認可認証サーバーのLambda関数
    const authFunction = new lambda.Function(this, "AuthFunction", {
      code: lambda.Code.fromAsset("./lib/lambda/auth/dist"),
      runtime: lambda.Runtime.NODEJS_14_X,
      handler: "main.handler",
    });

    // 認可認証サーバーのLambda関数のロググループ
    const authFunctionLog = new logs.LogGroup(this, "AuthFunctionLogGroup", {
      logGroupName: `/aws/lambda/${authFunction.functionName}`,
      removalPolicy: RemovalPolicy.DESTROY,
    });

    // 認可認証サーバーのAPI Gateway
    const restApi = new apigateway.RestApi(this, "RestAPI", {
      endpointTypes: [apigateway.EndpointType.REGIONAL],
      defaultCorsPreflightOptions: {
        allowOrigins: ["http://localhost:3000"],
        allowMethods: apigateway.Cors.ALL_METHODS,
        allowHeaders: apigateway.Cors.DEFAULT_HEADERS,
        statusCode: 200,
      },
      restApiName: "cognito-test-rest-api",
    });

    const authFunctionIntegration = new apigateway.LambdaIntegration(
      authFunction
    );

    // "/auth"
    const authResource = restApi.root.addResource("auth");

    // "/users"
    const usersResource = authResource.addResource("users");
    usersResource.addMethod("POST", authFunctionIntegration);

    // "/users/confirm"
    const usersConfirmResource = usersResource.addResource("confirm");
    usersConfirmResource.addMethod("POST", authFunctionIntegration);
  }
}
```

※1 任意の文字列(ただし他のAWSアカウントがすでに使用している値は不可)
※2 サインアップ確認メールをCognitoで送信する([無料だけど1日あたりの送信数に制限がある](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/user-pool-email.html#user-pool-email-default)ので本番ではSESを使おう)
※3 クライアントシークレットを作成する(SPAなどバックエンドがないクライアントを使う場合はfalseにする)

### 認可認証サーバー
Cognito UserPool API(SignUp、ConfirmSignUp)を叩く認可認証サーバーのコードを書く
言語はTypeScript、ライブラリは[express](https://expressjs.com/ja/)、[serverless-express](https://github.com/vendia/serverless-express)、[aws-sdk-v3](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/index.html)を使う

#### 設定ファイルを用意

```infra/lib/lambda/auth/package.json
{
  "name": "auth",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "webpack"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@types/express": "^4.17.13",
    "ts-loader": "^9.2.6",
    "typescript": "^4.5.5",
    "webpack": "^5.69.1",
    "webpack-cli": "^4.9.2"
  },
  "dependencies": {
    "@aws-sdk/client-cognito-identity-provider": "^3.53.0",
    "@vendia/serverless-express": "^4.5.4",
    "crypto": "^1.0.1",
    "express": "^4.17.3"
  }
}
```

書き終わったら`npm install`しておく

```infra/lib/lambda/auth/tsconfig.json
{
  "compilerOptions": {
    "sourceMap": true,
    "target": "ES2021",
    "module": "ES2020",
    "moduleResolution": "node"
  }
}
```

```infra/lib/lambda/auth/webpack.config.js
const path = require("path");

module.exports = {
  mode: "development",
  entry: {
    handler: path.resolve(__dirname, "src/main.ts"),
  },
  module: {
    rules: [
      {
        test: /\.ts/,
        use: "ts-loader",
      },
    ],
  },
  target: "node",
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "main.js",
    libraryTarget: "commonjs",
  },
  resolve: {
    extensions: [".ts", ".js"],
  },
};
```

#### 実装

```infra/lib/lambda/auth/src/main.ts
import serverless from "@vendia/serverless-express";
import * as express from "express";
import {
  CognitoIdentityProvider,
  ConfirmSignUpCommand,
  SignUpCommand,
} from "@aws-sdk/client-cognito-identity-provider";
import { createHmac } from "crypto";

const cognito = new CognitoIdentityProvider({});

function secretHash(
  userName: string,
  clientId: string,
  clientSecret: string
): string {
  return createHmac("sha256", clientSecret)
    .update(userName + clientId)
    .digest("base64");
}

const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

const router = express.Router();

router.post("/auth/users", async (req, res) => {
  const signUpCommand = new SignUpCommand({
    ClientId: req.body.client_id,
    SecretHash: secretHash(
      req.body.email,
      req.body.client_id,
      req.body.client_secret
    ),
    Username: req.body.email,
    Password: req.body.password,
  });
  await cognito.send(signUpCommand).catch((error) => {
    console.error(error);
    res.status(400).send({
      message: "SignUp failed.",
    });
  });
  res.status(200).send({
    message: "SignUp succeed",
  });
});
router.post("/auth/users/confirm", async (req, res) => {
  const confirmSignUpCommand = new ConfirmSignUpCommand({
    ClientId: req.body.client_id,
    SecretHash: secretHash(
      req.body.email,
      req.body.client_id,
      req.body.client_secret
    ),
    Username: req.body.email,
    ConfirmationCode: req.body.confirmation_code,
  });
  await cognito.send(confirmSignUpCommand).catch((error) => {
    console.error(error);
    res.status(400).send({
      message: "ConfirmSignUp failed",
    });
  });
  res.status(200).send({
    message: "ConfirmSignUp succeed",
  });
});

app.use("/", router);

exports.handler = serverless({
  app: app,
});
```

main.tsがかけたら`npm run build`でビルド
`dist/main.js`が作成されていればOK

### デプロイ

`/infra`に戻って`cdk deploy InfraStack`でデプロイする
デプロイできたらCognito UserPoolのアプリクライアントから`ClientSecret`を取得し、`infra-stack.ts`でハードコードしていた部分を書き換えて再デプロイする(これでlambdaの環境変数が書き換えられる)

## クライアントの実装
フロントエンドはHTML、バックエンドはexpressで実装する

### フロントエンド

```client/public/index.html
<html lang="ja">
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <title>Cognitoテスト</title>
  </head>
  <body>
    <h1>Cognitoテスト</h1>

    <h2>サインアップ</h2>
    <form class="signup-form" action="/auth/users" method="post">
      <div class="form-row">
        <label for="email">メールアドレス</label>
        <input type="email" name="email" />
      </div>
      <div class="form-row">
        <label for="password">パスワード</label>
        <input type="password" name="password" />
      </div>
      <div class="form-row">
        <input type="submit" value="実行" />
      </div>
    </form>

    <h2>サインアップ確認</h2>
    <form
      class="confirm-signup-form"
      action="/auth/users/confirm"
      method="post"
    >
      <div class="form-row">
        <label for="email">メールアドレス</label>
        <input type="email" name="email" />
      </div>
      <div class="form-row">
        <label for="confirmation_code">確認コード</label>
        <input name="confirmation_code" />
      </div>
      <div class="form-row">
        <input type="submit" value="実行" />
      </div>
    </form>
  </body>
</html>
```

### バックエンド

設定ファイルはLambdaのディレクトリのものと似たような感じで
使うライブラリは↓

```
npm i express node-fetch path
npm i -D @types/express typescript webpack webpack-cli
```

```client/src/main.ts
import * as express from "express";
import * as path from "path";
import fetch from "node-fetch";

const STATIC_DIR = path.join(__dirname, "../public");
const HTML_PATH = path.join(STATIC_DIR, "index.html");
const API_GATEWAY_EDNPOINT = ""; // ※
const CLIENT_ID = ""; // ※
const CLIENT_SECRET = ""; // ※

const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static(STATIC_DIR));

const router = express.Router();

router.get("/", (_, res) => res.sendFile(HTML_PATH));
router.post("/auth/users", async (req, res) => {
  const headers = {
    "Content-Type": "application/json",
  };
  const body = JSON.stringify({
    email: req.body.email,
    password: req.body.password,
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
  });
  await fetch(API_GATEWAY_EDNPOINT + "/auth/users", {
    method: "POST",
    headers,
    body,
  });
  res.sendFile(HTML_PATH);
});
router.post("/auth/users/confirm", async (req, res) => {
  const headers = {
    "Content-Type": "application/json",
  };
  const body = JSON.stringify({
    email: req.body.email,
    confirmation_code: req.body.confirmation_code,
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
  });
  await fetch(API_GATEWAY_EDNPOINT + "/auth/users/confirm", {
    method: "POST",
    headers,
    body,
  });
  res.sendFile(HTML_PATH);
});

app.use("/", router);

app.listen(3000, () => console.log("server listen on port 3000"));
```

※各パラメータをハードコードする

## 動作確認

できたら`npm run build`→`node dist/main.js`で実行
ブラウザでhttp://localhost:3000にアクセス

![スクリーンショット 2022-02-27 164051.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2533663/d8446475-6094-77ab-6d25-0191c5a7dd80.jpeg)
こんな感じの画面が表示されるのでメールアドレスとパスワードを入力してサブミット
するとサインアップの確認コードメールが届く
![スクリーンショット 2022-02-27 164305.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2533663/c7a86cb2-3fb7-c536-6a84-27f0f6d26f39.jpeg)
UserPoolを確認するとユーザーが作成されている
![スクリーンショット 2022-02-27 164800.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2533663/783e74ef-813f-1de0-9c24-1003dd831db3.jpeg)

確認コードを入力してサブミットすると確認ステータスが確認済みになる
![スクリーンショット 2022-02-27 164906.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2533663/5605b9e8-c57d-c87f-d8c7-6a033d2e40f2.jpeg)

# トークン取得
Cognitoのユーザー作成に成功したのでOAuth2.0のAuthorizationCodeGrantでアクセストークンを取得する

## シーケンス図

```plantuml
@startuml SignIn

title トークン取得シーケンス

participant フロントエンド
participant バックエンド
participant Cognito

フロントエンド -> バックエンド: 1. GET /auth/token
バックエンド -> バックエンド: 2. stateを生成してCookieに保存
バックエンド --> フロントエンド: 3. 認可URIをレスポンス
note left バックエンド #LightGreen
https://{CUSTOM_DOMAIN}.auth.{REGION}.amazoncognito.com/oauth2/authorize?
                                                                                                          response_type=code&
                                                                                                          client_id={CLIENT_ID}&
                                                                                                          scope={SCOPE}&
                                                                                                          redirect_uri={REDIRECT_URI}&
                                                                                                          state={STATE}
end note
フロントエンド -> Cognito: 4. Redirect {認可URI}
Cognito --> フロントエンド: 5. 認可画面へリダイレクト
フロントエンド -> バックエンド: 6. Redirect URIへリダイレクト
note right フロントエンド 
{REDIRECT_URI}?code={AUTHORIZATION_CODE}&state={STATE}
end note
バックエンド -> バックエンド: Cookieのstateとクエリパラメータのstateを検証
バックエンド -> Cognito: 7. POST /oauth2/token
note right バックエンド
{
  "grant_type": "authorization_code",
  "client_id": "{CLIENT_ID}",
  "code": "{AUTHORIZATION_CODE}",
  "redirect_uri": "{REDIRECT_URI}"
}
end note

Cognito --> バックエンド: 8.
note left Cognito #LightGreen
{
  "access_token": "{ACCESS_TOKEN}",
  "id_token": "{ID_TOKEN}",
  "refresh_token": "{REFRESH_TOKEN}",
  "expire_in": 3600
}
end note
@enduml
```

## 実装

`client/`に移動してライブラリを追加する

```bash
npm i uuid cookie-parser
npm i -D @types/uuid @types/cookie-parser
```

バックエンドを編集

```client/src/main.ts
import * as express from "express";
import * as path from "path";
import fetch from "node-fetch";
// 追加
import { v4 } from "uuid";
import * as cookieParser from "cookie-parser";
// ここまで

const STATIC_DIR = path.join(__dirname, "../public");
const HTML_PATH = path.join(STATIC_DIR, "index.html");
const API_GATEWAY_EDNPOINT = "";
const CLIENT_ID = "";
const CLIENT_SECRET = "";
// 追加
const CUSTOM_DOMAIN = "";
const REGION = "";
const REDIRECT_URI = "http://localhost:3000/auth/authorize/callback";
const COGNITO_ENDPOINT = `https://${CUSTOM_DOMAIN}.auth.${REGION}.amazoncognito.com`;
// ここまで

const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static(STATIC_DIR));
// 追加
app.use(cookieParser());
// ここまで

const router = express.Router();

router.get("/", (_, res) => res.sendFile(HTML_PATH));
router.post("/auth/users", async (req, res) => {
  const headers = {
    "Content-Type": "application/json",
  };
  const body = JSON.stringify({
    email: req.body.email,
    password: req.body.password,
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
  });
  await fetch(API_GATEWAY_EDNPOINT + "/auth/users", {
    method: "POST",
    headers,
    body,
  });
  res.sendFile(HTML_PATH);
});
router.post("/auth/users/confirm", async (req, res) => {
  const headers = {
    "Content-Type": "application/json",
  };
  const body = JSON.stringify({
    email: req.body.email,
    confirmation_code: req.body.confirmation_code,
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
  });
  await fetch(API_GATEWAY_EDNPOINT + "/auth/users/confirm", {
    method: "POST",
    headers,
    body,
  });
  res.sendFile(HTML_PATH);
});
// 追加
router.get("/auth/token", (_, res) => {
  // stateをCookieに保存
  const state = v4();
  res.cookie("state", state, {
    httpOnly: true,
  });
  const url = COGNITO_ENDPOINT + "/oauth2/authorize";
  const query = `?response_type=code&client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&state=${state}`;
  res.redirect(url + query);
});
router.get("/auth/authorize/callback", async (req, res) => {
  // Cookieのstateとレスポンスのstateが一致するか確認
  if (req.query.state !== req.cookies.state) {
    res.status(400).send({
      message: "Authorization failed.",
    });
  }

  const headers = {
    "Content-Type": "application/x-www-form-urlencoded",
    Authorization:
      "Basic " +
      Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64"),
  };
  const params = new URLSearchParams();
  params.append("grant_type", "authorization_code");
  params.append("code", req.query.code as string);
  params.append("redirect_uri", REDIRECT_URI);
  const tokens = await (
    await fetch(COGNITO_ENDPOINT + "/oauth2/token", {
      method: "POST",
      headers: headers,
      body: params,
    })
  ).json();
  console.log(JSON.stringify(tokens, null, 2));
  res.status(200).send({ message: "succeed" });
});
// ここまで

app.use("/", router);

app.listen(3000, () => console.log("server listen on port 3000"));
```

## 動作確認

`npm run build`→`node dist main.js`でサーバーを起動してhttp://localhost:300 にアクセス

![スクリーンショット 2022-03-02 220315.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2533663/ec29bcd9-ec30-e190-55d7-8845696b0ea8.jpeg)

`実行`をクリックするとCognitoの認証画面へリダイレクトする
![スクリーンショット 2022-03-02 220422.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2533663/f6284b54-ec9a-3571-8187-16314677af56.jpeg)

`Email` `Password`を入力して`Sign in`をクリック
するとローカルに建てたサーバーへリダイレクトしIDTokenを取得する処理が走る
サーバーのログに以下のように表示されていれば成功

```json
{
  "id_token": "ID_TOKEN",
  "expires_in": 3600,
  "token_type": "Bearer"
}
```