AWS CDKでCognito認証されたAPI Gateway(HTTP API)を構築する

2021.07.17

はじめに

おはようございます、加藤です。今回はAWS CDKを使ってAmazon API Gateway(HTTP API = v2)にAmazon Cognitoを使って認証を設定する方法をまとめてみます。 また作成されたAPIに対してOpenAPI定義を作成し、それをSwagger UIでプレビュー&(認証された状態で)API呼び出しする方法も合わせて説明します。

リポジトリのセットアップ

aws-cdkコマンドを使ってリポジトリを生成します。

mkdir cdk-demo-apigw-with-cognito
cd cdk-demo-apigw-with-cognito
npx -p aws-cdk cdk init app --language typescript

必要な依存関係をインストールします。

npm i -D \
  @aws-cdk/aws-apigatewayv2 \
  @aws-cdk/aws-apigatewayv2-integrations \
  @aws-cdk/aws-apigatewayv2-authorizers \
  @aws-cdk/aws-cognito \
  @aws-cdk/aws-lambda-nodejs \
  @types/aws-lambda \ 
  esbuild@0

AWSリソースの構築

API GatewayでCognitoを使って認証するためにはCognito UserPoolとUserPool Clientを作成し、それをAuthorizerとしてAPIに関連付けします。 気をつけて欲しいポイントとしてパス/へのアクセスは特別なものとして扱われ、特にCORS周りで特殊な設定が必要になるので特別な理由が無ければ使わないほうが無難です。 HTTP API のルートの使用 - Amazon API Gateway # $default ルートの操作

// lib/cdk-demo-apigw-with-cognito-stack.ts
import * as cdk from '@aws-cdk/core';
import * as cognito from '@aws-cdk/aws-cognito';
import * as apigw from '@aws-cdk/aws-apigatewayv2';
import {HttpMethod} from '@aws-cdk/aws-apigatewayv2/lib/http/route';
import * as intg from '@aws-cdk/aws-apigatewayv2-integrations';
import * as nodejs from '@aws-cdk/aws-lambda-nodejs';
import * as authz from '@aws-cdk/aws-apigatewayv2-authorizers';

export interface CdkDemoApigwWithCognitoStackProps exte nds cdk.StackProps {
  callbackUrls: string[];
  logoutUrls: string[];
  frontendUrls: string[];
  domainPrefix: string;
}

export class CdkDemoApigwWithCognitoStack extends cdk.Stack {
  constructor(
    scope: cdk.Construct,
    id: string,
    props: CdkDemoApigwWithCognitoStackProps
  ) {
    super(scope, id, props);

    const userPool = new cognito.UserPool(this, 'userPool', {
      selfSignUpEnabled: false,
      standardAttributes: {
        email: {required: true, mutable: true},
        phoneNumber: {required: false},
      },
      signInCaseSensitive: false,
      autoVerify: {email: true},
      signInAliases: {email: true},
      accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });
    userPool.addDomain('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 handler = new nodejs.NodejsFunction(this, 'Handler');

    const authorizer = new authz.HttpUserPoolAuthorizer({
      authorizerName: 'CognitoAuthorizer',
      userPool,
      userPoolClient,
    });

    const httpApi = new apigw.HttpApi(this, 'Api', {
      defaultAuthorizer: authorizer,
      corsPreflight: {
        allowOrigins: props.frontendUrls,
        allowMethods: [apigw.CorsHttpMethod.ANY],
        allowHeaders: ['authorization'],
      },
    });
    httpApi.addRoutes({
      methods: [HttpMethod.GET],
      path: '/pets',
      integration: new intg.LambdaProxyIntegration({handler}),
    });

    new cdk.CfnOutput(this, 'OutputApiUrl', {value: httpApi.url!});
    new cdk.CfnOutput(this, 'OutputDomainPrefix', {value: props.domainPrefix});
  }
}

API Gatewayのバックエンドで処理を行うLambda関数を作成します。@aws-cdk/aws-lambda-nodejsはプロパティを指定しないと${stackFileName}.${CdkComponentId}.tsのファイルをエントリーポイントとしてesbuildを使ってバンドリングを行います。(コンポーネントIDは大文字小文字を区別します)

下記のようにペット一覧のモックデータを返す処理を実装します。

// lib/cdk-demo-apigw-with-cognito-stack.Handler.ts
import {APIGatewayProxyHandlerV2} from 'aws-lambda';

interface Pet {
  name: string;
  age: number;
}

interface Response {
  pets: Pet[];
}

export const handler: APIGatewayProxyHandlerV2 = async () => {
  return {
    statusCode: 200,
    body: JSON.stringify({
      pets: [
        {
          name: 'hina',
          age: 1,
        },
        {
          name: 'koharu',
          age: 2,
        },
        {
          name: 'konatsu',
          age: 3,
        },
      ],
    } as Response),
  };
};

エントリーファイルを変更し、スタックが求めるプロパティを注入します。

// bin/cdk-demo-apigw-with-cognito.ts
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from '@aws-cdk/core';
import {CdkDemoApigwWithCognitoStack} from '../lib/cdk-demo-apigw-with-cognito-stack';

const app = new cdk.App();
new CdkDemoApigwWithCognitoStack(app, 'CdkDemoApigwWithCognitoStack', {
  callbackUrls: ['http://localhost:3200/'],
  logoutUrls: ['http://localhost:3200/'],
  frontendUrls: ['http://localhost:3200'],
  domainPrefix: process.env.DOMAIN_PREFIX!,
});

環境変数DOMAIN_PREFIXを設定し、デプロイします。 デプロイ時に出力されたOutputは後ほどOpenAPI定義を作成する際とSwagger UIを使う際に使用するのでメモしておいてください。

DOMAIN_PREFIX=任意の英数記号(一部の予約語は使用できない、cognitoなど)
npm run cdk deploy

Swagger UIでのプレビューとAPI呼び出し

OpenAPI定義を作成します。なお、プレースホルダーの部分をメモしたOutputに差し替えてください。

# docs/openapi.yaml
openapi: 3.0.3
info:
  title: Petstore API overview
  version: 1.0.0
servers:
  - url: {{OutputApiUrl}}
components:
  securitySchemes:
    OAuth2:
      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
paths:
  /pets:
    get:
      operationId: listPets
      security:
        - OAuth2: [ openid, token ]
      responses:
        200:
          description: 200 OK
          content:
            application/json:
              schema:
                required:
                  - pets
                properties:
                  pets:
                    type: array
                    items:
                      required:
                        - name
                        - age
                      properties:
                        name:
                          type: string
                        age:
                          type: number
                example:
                  pets:
                    - name: hina
                      age: 1
                    - name: koharu
                      age: 2
                    - name: konatsu
                      age: 3

このOpenAPI定義をSwagger UIでプレビューします。Docker上で実行する為にdocker-compose.yamlを書きます。

# docker-compose.yaml
version: '3.8'

services:
  swagger:
    image: swaggerapi/swagger-ui
    environment:
      API_URL: /swagger.yaml
      BASE_URL: /
    env_file: .env
    volumes:
      - ./docs/openapi.yaml:/usr/share/nginx/html/swagger.yaml
    ports:
      - 3200:8080

Client IDを環境変数に設定しSwagger UIを起動します。

OAUTH_CLIENT_ID=CDKからアウトプットされたClient ID
docker compose up

Swagger UIを使ったAPI呼び出しは下記のブログを参考にしてください。(今回の手順ではClient IDが予め設定されています、Client Secretは設定する必要がありません。)

Swagger 3.0のOAuth認証にCognito User PoolsのOAuth Clientを使う | DevelopersIO

あとがき

今回作成したコードはこちらで公開しています。 intercept6/cdk-demo-apigw-with-cognito

OpenAPI定義へのパラメーターは手動で書き換えていますが、CDKのアウトプットはjsonに書き出すことが可能なのでJinja2などを使えば自動的に更新することも出来そうだなーと思いました。ただ、カスタムドメインの割当ができれば大抵は不要なので必要なケースは限定的ですね。

参考元