AWS CDKv2でCognito認証されたAPI Gatewayを構築して swaggeruiで疎通確認したメモ

## 概要
[AWS CDKでCognito認証されたAPI Gateway(HTTP API)を構築する](https://dev.classmethod.jp/articles/aws-cdk-apigw-cognito/)をcdk v2で試してみた。

[ソースコード(API)](https://github.com/hibohiboo/aws-cdk-v2/commit/909eee5f148a5042557b6f7022c64a612623fa94)
[ソースコード(Swagger)](https://github.com/hibohiboo/kartagraph/commit/3e8e305744322d50a8a1f30f1ea0e40546cfd1d9)

## 環境

- windows10
- docker-desktop(Docker version 20.10.13, build a224086)
- cdk 2.16.0

## CDK構築

<details><summary>package.json</summary><div>

```json:cdk/package.json
{
  "name": "cdk",
  "version": "0.1.0",
  "bin": {
    "cdk": "bin/cdk.js"
  },
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "test": "jest",
    "lambda-with-cognito-deploy": "cdk -a \"npx ts-node --prefer-ts-exts bin/lambdaWithCognito.ts\" deploy LambdaWithCognitoStack -c @aws-cdk/core:newStyleStackSynthesis=true"
  },
  "devDependencies": {
    "@types/fs-extra": "^9.0.13",
    "@types/jest": "^27.4.1",
    "@types/node": "17.0.21",
    "aws-cdk": "2.16.0",
    "fs-extra": "^10.0.1",
    "jest": "^27.5.1",
    "ts-jest": "^27.1.3",
    "ts-node": "^10.7.0",
    "typescript": "~4.6.2"
  },
  "dependencies": {
    "@aws-cdk/aws-apigatewayv2-alpha": "^2.16.0-alpha.0",
    "@aws-cdk/aws-apigatewayv2-authorizers-alpha": "^2.16.0-alpha.0",
    "@aws-cdk/aws-apigatewayv2-integrations-alpha": "^2.16.0-alpha.0",
    "aws-cdk-lib": "2.16.0",
    "constructs": "^10.0.89",
    "dotenv": "^16.0.0",
    "eslint": "^8.11.0",
    "source-map-support": "^0.5.21"
  }
}
```

</div></details>

```config:cdk/.env
PROJECT_ID=hogefuga
DOMAIN_PREFIX=piyo
CALLBACK_URLS=http://localhost:3200/oauth2-redirect.html
LOGOUT_URLS=http://localhost:3200/oauth2-redirect.html
FRONTEND_URLS=http://localhost:3200
```

```ts:cdk/lib/LambdaWithCognitoStack.ts
import * as lambda from 'aws-cdk-lib/aws-lambda'
import * as cognito from 'aws-cdk-lib/aws-cognito'
import * as apigw from '@aws-cdk/aws-apigatewayv2-alpha'
import * as intg from '@aws-cdk/aws-apigatewayv2-integrations-alpha'
import * as authz from '@aws-cdk/aws-apigatewayv2-authorizers-alpha'
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'
import { CfnOutput, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib'
import { Construct } from 'constructs'

interface Props extends StackProps {
  projectId: string
  domainPrefix: string
  frontendUrls: string[]
  callbackUrls: string[]
  logoutUrls: string[]
}
export class LambdaWithCognitoStack extends Stack {
  constructor(scope: Construct, id: string, props: Props) {
    super(scope, id, props)

    const userPool = new cognito.UserPool(this, `${props.projectId}-userPool`, {
      selfSignUpEnabled: false,
      standardAttributes: {
        email: { required: true, mutable: true },
        phoneNumber: { required: false },
      },
      signInCaseSensitive: true,
      autoVerify: { email: true },
      signInAliases: { email: true },
      accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
      removalPolicy: RemovalPolicy.DESTROY,
    })
    userPool.addDomain(`${props.projectId}-userPool-domain`, {
      cognitoDomain: { domainPrefix: props.domainPrefix },
    })
    const userPoolClient = userPool.addClient('client', {
      oAuth: {
        scopes: [
          cognito.OAuthScope.EMAIL,
          cognito.OAuthScope.OPENID,
          cognito.OAuthScope.PROFILE,
        ],

        callbackUrls: props.callbackUrls,
        logoutUrls: props.logoutUrls,
        flows: { authorizationCodeGrant: true },
      },
    })
    const authorizer = new authz.HttpUserPoolAuthorizer(
      `${props.projectId}-CognitoAuthorizer`,
      userPool,
      {
        authorizerName: 'CognitoAuthorizer',
        userPoolClients: [userPoolClient],
      },
    )

    const handler = new NodejsFunction(
      this,
      `${props.projectId}-scenario-lambda`,
      {
        runtime: lambda.Runtime.NODEJS_14_X,
        entry: '../src/handler/api/getScenario.ts',
        functionName: 'scenario',
        description: 'シナリオ',
      },
    )

    const httpApi = new apigw.HttpApi(this, `${props.projectId}-apigw`, {
      createDefaultStage: false,
      corsPreflight: {
        allowOrigins: props.frontendUrls,
        allowMethods: [apigw.CorsHttpMethod.ANY],
        allowHeaders: ['authorization'],
      },
    })

    httpApi.addRoutes({
      methods: [apigw.HttpMethod.GET],
      path: '/scenario',
      integration: new intg.HttpLambdaIntegration(
        `${props.projectId}-scenarioIntegration`,
        handler,
      ),
      authorizer,
    })
    const stage = new apigw.HttpStage(this, `${props.projectId}-apistage`, {
      httpApi,
      stageName: 'api',
      autoDeploy: true,
    })

    new CfnOutput(this, 'OutputApiUrl', { value: stage.url })
    new CfnOutput(this, 'OutputDomainPrefix', { value: props.domainPrefix })
    new CfnOutput(this, 'OutputClientId', {
      value: userPoolClient.userPoolClientId,
    })
  }
}
```

```ts:cdk/bin/lambdaWithCognito.ts
#!/usr/bin/env node
import 'source-map-support/register'
import * as cdk from 'aws-cdk-lib'
import { LambdaWithCognitoStack } from '../lib/LambdaWithCognitoStack'
import * as dotenv from 'dotenv'

dotenv.config()
const envList = [
  'PROJECT_ID',
  'DOMAIN_PREFIX',
  'CALLBACK_URLS',
  'LOGOUT_URLS',
  'FRONTEND_URLS',
] as const
for (const key of envList) {
  if (!process.env[key]) throw new Error(`please add ${key} to .env`)
}
const processEnv = process.env as Record<typeof envList[number], string>

const app = new cdk.App()
const env = {
  account: process.env.CDK_DEFAULT_ACCOUNT,
  region: process.env.CDK_DEFAULT_REGION,
}
const projectId = processEnv.PROJECT_ID

new LambdaWithCognitoStack(app, `${projectId}-stack`, {
  projectId,
  domainPrefix: processEnv.DOMAIN_PREFIX,
  callbackUrls: processEnv.CALLBACK_URLS.split(','),
  logoutUrls: processEnv.LOGOUT_URLS.split(','),
  frontendUrls: processEnv.FRONTEND_URLS.split(','),
  env
})
```

デプロイ後に表示されるURL、clientID、OutputDomainPrefixは次のSwaggerの環境変数に使うため控えておく。


## SWaggerUIでの確認
SwaggerUIが現在(2022/03/17)openapi3.0.3までしか対応していないので、3.1で書いたファイルを一つにまとめた後、versionを3.1から3.0に書き換えるスクリプトを追記した。

[redocを使ってもいいかもしれない。](https://qiita.com/hibohiboo/items/5073e28becc49719208a)


### openapi

```json:documents/api-schema/package.json
{
  "name": "app",
  "version": "0.0.1",
  "description": "",
  "main": "index.js",
  "scripts": {
    "redoc": "redoc-cli bundle ./openapi.yml --options.menuToggle --options.pathInMiddlePanel  -o ./dist/index.html",
    "preswaggerui": "openapi bundle openapi.yml --output swagger.yaml && bash replace.sh",
    "swaggerui": "bash ./bin/swagger.sh"
  },
  "keywords": [],
  "author": "hibohiboo <hibohiboo66+github@gmail.com>",
  "license": "MIT",
  "devDependencies": {
    "@redocly/openapi-cli": "^1.0.0-beta.87",
    "redoc": "^2.0.0-rc.65",
    "redoc-cli": "^0.13.9"
  }
}
```

```:documents/api-schema/.env.sample
OUTPUT_API_URL=https://hogehoge.execute-api.ap-northeast-1.amazonaws.com/api
OUTPUT_DOMAIN_PREFIX=piyo
```

```sh:documents/api-schema/replace.sh
#!/bin/bash

. .env

sed -i -e "s/openapi: 3.1.0/openapi: 3.0.3/" swagger.yaml
sed -i -e "s|{{OutputApiUrl}}|$OUTPUT_API_URL|" swagger.yaml
sed -i -e "s|{{OutputDomainPrefix}}|$OUTPUT_DOMAIN_PREFIX|g" swagger.yaml
```

```yml:documents/api-schema/openapi.yml
openapi: 3.1.0
info:
  title: hoge
  description: fugaAPI
  version: 0.0.1
servers:
  - url: "{{OutputApiUrl}}"
paths:
  /scenario:
    $ref: ./paths/cartagraph.yml

components:
  securitySchemes:
    OAuth2:
      $ref: ./components/schemas/oauth2.yml
```

```yml:documents/api-schema/components/schemas/oauth2.yml
type: oauth2
description: For more information, see https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-userpools-server-contract-reference.html
flows:
  authorizationCode:
    authorizationUrl: https://{{OutputDomainPrefix}}.auth.ap-northeast-1.amazoncognito.com/oauth2/authorize
    tokenUrl: https://{{OutputDomainPrefix}}.auth.ap-northeast-1.amazoncognito.com/oauth2/token
    scopes:
      openid: openid token
```

```yml:documents/api-schema/components/schemas/oauth2.yml
type: oauth2
description: For more information, see https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-userpools-server-contract-reference.html
flows:
  authorizationCode:
    authorizationUrl: https://{{OutputDomainPrefix}}.auth.ap-northeast-1.amazoncognito.com/oauth2/authorize
    tokenUrl: https://{{OutputDomainPrefix}}.auth.ap-northeast-1.amazoncognito.com/oauth2/token
    scopes:
      openid: openid token
```

```yml:documents/api-schema/paths/cartagraph.yml
get:
  operationId: scenarios
  security:
    - OAuth2: [ openid, token ]
  responses:
    200:
      description: 200 OK
      content:
        application/json:
          schema:
            required:
              - scenarios
            properties:
              scenarios:
                type: array
                items:
                  required:
                    - name
                  properties:
                    name:
                      type: string
            example:
              scenarios:
                - name: hina
                - name: koharu
                - name: konatsu
```              

### swaggerui

```yml:documents/api-schema/docker/docker-compose.yml
version: '3.8'

services:
  swagger:
    image: swaggerapi/swagger-ui:latest
    environment:
      API_URL: /swagger.yaml
      BASE_URL: /
    env_file: .env
    volumes:
      - ../swagger.yaml:/usr/share/nginx/html/swagger.yaml
      - ../components:/usr/share/nginx/html/components
      - ../paths:/usr/share/nginx/html/paths
    ports:
      - 3200:8080
 ```

```conf:documents/api-schema/docker/.env
OAUTH_CLIENT_ID=発行されたcliendID
DOMAIN_PREFIX=piyo
```

## cognitoの設定
デプロイされたらコンソールにログインし、cognitoの画面から検証用のユーザを作成する。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/0be08478-5dcd-2d22-1dcd-a36c90a3116b.png)


## 動作確認
dockerを起動し、 http://localhost:3200/に移動。

### APIの認証有効確認
ただ叩いて401が帰ってくれば正常
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/d2ddb307-acee-cd1a-7ab0-8878b87cabdd.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/dc2f510b-25b0-063a-4d0f-2b6f49184101.png)
401であることを確認

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/e8b076d7-5e9a-37af-467a-4a8a30b8c669.png)


### 認証

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/ae1ba90f-f4b6-f555-4119-d22888629218.png)

client secretには何も入れずにAuthorizeをクリック

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/3e8fbac9-0b1a-2cec-ac26-e97a927e3e52.png)



![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/d767531c-b69d-e832-b21b-5b7fefea2601.png)

メールアドレスとパスワードを入力し、認証に成功すると元の画面が以下の表示となる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/2cdfc797-f1dc-b869-6106-29f8bd5227c2.png)

再度executeとして結果が変化することを確認。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/1faec23a-451c-fa7d-a884-1026cac11503.png)

## 遭遇したエラー

認証画面に飛ぼうとすると以下のエラー。
```
An error was encountered with the requested page.
```

原因はコールバックURLの設定ミス。

```bash
# エラーとなるコード
CALLBACK_URLS=http://localhost:3200/
# 成功するコード
CALLBACK_URLS=http://localhost:3200/oauth2-redirect.html
```


## 参考
[AmplifyでCognitoのHosted UIを利用した認証を最低限の実装で動かしてみて動作を理解する](https://dev.classmethod.jp/articles/learn-authentication-using-cognitos-hosted-ui-with-amplify/)
[AWS SAMでCognito UserPoolsの認証付きAPIを作成する](https://blog.youyo.info/post/2018/12/05/sam-apigateway-authorizer/)
[cdk](https://docs.aws.amazon.com/cdk/api/v2/docs/@aws-cdk_aws-apigatewayv2-authorizers-alpha.HttpLambdaAuthorizer.html)
[Amazon Cognito ユーザープールをオーソライザーとして使用して REST API へのアクセスを制御する](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-integrate-with-cognito.html)
[Amplify auth は複数のコールバックURLに対応していない](https://qiita.com/jacoyutorius/items/5fc27ca70ef8b672a96b)
[OPENAPI](https://www.openapis.org/)
[redocly](https://redocly.com/docs/cli/quickstart/)