【AWS CDK】API Gateway と Cognito で Client Credentials Grant による認証を試してみた

はじめに

テントの中から失礼します、CX事業本部のてんとタカハシです!

API Gateway と Cognito で Client Credentials Grant による認証を行うための構成を CDK で作成して試してみました。

マネコン上で同じようなことを試す場合は、下記の記事が参考になります。

ソースコードは下記のリポジトリに置いています。

GitHub - iam326/cognito-client-credentials-by-cdk

環境

環境は下記の通りです。

$ cdk --version
1.102.0 (build a75d52f)

$ yarn --version
1.22.10

$ node --version
v14.7.0

$ jq --version
jq-1.6

実装

スタック

作成する主なリソースは下記になります。

  • Cognito
    • ユーザープールを作成
    • 読み取り専用・全アクセス可能なスコープを持つリソースサーバーを作成
    • ユーザープールのドメインを設定
  • API Gateway
    • REST API を作成
    • リソース/usersに対して GET メソッド、POST メソッドを作成
    • GET メソッドは、読み取り専用・全アクセススコープどちらでもアクセス可能に設定
    • POST メソッドは、全アクセススコープでのみアクセス可能に設定

lib/cognito-client-credentials-by-cdk-stack.ts

import * as cdk from '@aws-cdk/core';
import * as apigateway from '@aws-cdk/aws-apigateway';
import * as cognito from '@aws-cdk/aws-cognito';
import * as lambda from '@aws-cdk/aws-lambda';

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

    const projectName: string = this.node.tryGetContext('projectName');
    
    // * Cognito

    const userPool = new cognito.UserPool(this, 'UserPool', {
      userPoolName: `${projectName}-user-pool`,
    });
    
    // 読み取り専用スコープ
    const readOnlyScope = new cognito.ResourceServerScope({
      scopeName: 'read',
      scopeDescription: 'Read-only access',
    });
    // 全アクセススコープ
    const fullAccessScope = new cognito.ResourceServerScope({
      scopeName: '*',
      scopeDescription: 'Full access',
    });
    userPool.addResourceServer('ResourceServer', {
      identifier: 'users',
      scopes: [readOnlyScope, fullAccessScope],
    });
    
    userPool.addDomain('CognitoDomain', {
      cognitoDomain: {
        // 重複するとダメなので、必要に応じて変更が必要
        domainPrefix: 'client-credentials-sample',
      },
    });
    
    // * Lambda

    const helloWorldFunction = new lambda.Function(this, 'helloWorldFunction', {
      code: lambda.AssetCode.fromAsset('src/hello-world'),
      functionName: `${projectName}-hello-world`,
      handler: 'index.handler',
      runtime: lambda.Runtime.NODEJS_14_X,
      timeout: cdk.Duration.seconds(10),
      memorySize: 128,
    });
    
    // * API Gateway

    const restApi = new apigateway.RestApi(this, 'RestApi', {
      restApiName: `${projectName}-api`,
      deployOptions: {
        stageName: 'v1',
      },
      defaultCorsPreflightOptions: {
        allowOrigins: apigateway.Cors.ALL_ORIGINS,
        allowMethods: ['POST', 'OPTIONS', 'PUT', 'DELETE'],
        statusCode: 200,
      },
    });
    restApi.addGatewayResponse('Default4XXGatewayResponse', {
      type: apigateway.ResponseType.DEFAULT_4XX,
      responseHeaders: {
        'gatewayresponse.header.Access-Control-Allow-Headers':
          "'Content-Type,Authorization'",
        'gatewayresponse.header.header.Access-Control-Allow-Methods':
          "'OPTIONS,POST,PUT,GET,DELETE'",
        'gatewayresponse.header.header.Access-Control-Allow-Origin': "'*'",
      },
    });
    restApi.addGatewayResponse('Default5XXGatewayResponse', {
      type: apigateway.ResponseType.DEFAULT_5XX,
      responseHeaders: {
        'gatewayresponse.header.Access-Control-Allow-Headers':
          "'Content-Type,Authorization'",
        'gatewayresponse.header.header.Access-Control-Allow-Methods':
          "'OPTIONS,POST,PUT,GET,DELETE'",
        'gatewayresponse.header.header.Access-Control-Allow-Origin': "'*'",
      },
    });

    const authorizer = new apigateway.CfnAuthorizer(
      this,
      'APIGatewayAuthorizer',
      {
        name: 'authorizer',
        identitySource: 'method.request.header.Authorization',
        providerArns: [userPool.userPoolArn],
        restApiId: restApi.restApiId,
        type: apigateway.AuthorizationType.COGNITO,
      }
    );

    const methodOptionsWithAuth: apigateway.MethodOptions = {
      authorizationType: apigateway.AuthorizationType.COGNITO,
      authorizer: {
        authorizerId: authorizer.ref,
      },
    };

    const usersResource = restApi.root.addResource('users');
    usersResource.addMethod(
      'GET',
      new apigateway.LambdaIntegration(helloWorldFunction),
      {
        ...methodOptionsWithAuth,
        // GET メソッドは、読み取り専用・全アクセススコープどちらでもアクセス可能に設定
        authorizationScopes: ['users/read', 'users/*'],
      }
    );
    usersResource.addMethod(
      'POST',
      new apigateway.LambdaIntegration(helloWorldFunction),
      {
        ...methodOptionsWithAuth,
        // POST メソッドは、全アクセススコープでのみアクセス可能に設定
        authorizationScopes: ['users/*'],
      }
    );

    new cdk.CfnOutput(this, 'CognitoUserPoolId', {
      value: userPool.userPoolId,
      exportName: `CognitoUserPoolId`,
    });
  }
}

Lambda

Lambda の実装は単なるサンプルコードで、{ result: 'hello' }を返すだけです。

src/hello-world/index.js

exports.handler = function (event, context, callback) {
  console.log('Received event:', JSON.stringify(event, null, 2));

  const response = {
    statusCode: 200,
    headers: {
      'Access-Control-Allow-Headers': 'Content-Type,Authorization',
      'Access-Control-Allow-Methods': 'OPTIONS,POST,PUT,GET,DELETE',
      'Access-Control-Allow-Origin': '*',
    },
    body: JSON.stringify({ result: 'hello' }),
  };
  
  callback(null, response);
};

アプリクライアント作成スクリプト

下記を作成するスクリプトです。

  • 読み取り専用のスコープのみ許可されたアプリクライアント
  • 読み取り専用・全アクセスどちらも許可されたアプリクライアント

setup/create-app-client.sh

#!/bin/bash

set -euo pipefail

PROJECT_NAME="<PROJECT_NAME>"

USER_POOL_ID=$(aws cloudformation describe-stacks \
  --stack-name CognitoClientCredentialsByCdkStack \
  | jq -r '.Stacks[] | .Outputs[] | select(.OutputKey == "CognitoUserPoolId") | .OutputValue' \
)

# Read Only Client
aws cognito-idp create-user-pool-client \
  --user-pool-id ${USER_POOL_ID} \
  --client-name "read-only-client" \
  --generate-secret \
  --allowed-o-auth-flows "client_credentials" \
  --allowed-o-auth-scopes "users/read" \
  --allowed-o-auth-flows-user-pool-client

# Full Access Client
aws cognito-idp create-user-pool-client \
  --user-pool-id ${USER_POOL_ID} \
  --client-name "full-access-client" \
  --generate-secret \
  --allowed-o-auth-flows "client_credentials" \
  --allowed-o-auth-scopes "users/read" "users/*" \
  --allowed-o-auth-flows-user-pool-client

リソースをデプロイしてから、上記スクリプトを実行してアプリクライアントを作成してください。

アクセスしてみる

まず、アクセストークンを取得します。リクエストの際、Authorization の値として、下記コマンドの通り、認証情報を Base64 でエンコードした文字列が必要になります。

$ echo -n "<アプリクライアント ID>:<アプリクライアントのシークレット>" | base64

作成したユーザープールのトークンエンドポイントに対してリクエストします。ここでは、scope の値として読み取り専用(users/read)を指定しています。

リクエストが成功すると、アクセストークンが返ってきます。

$ curl -X POST 'https://client-credentials-sample.auth.ap-northeast-1.amazoncognito.com/oauth2/token' \
--header 'Authorization: Basic <Base64 エンコードした文字列>' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode 'scope=users/read'

{"access_token":"<アクセストークン>","expires_in":3600,"token_type":"Bearer"}

取得したアクセストークンを使って、作成した REST API の GET /users へアクセスします。リクエストが成功して、hello が返ってきます。

$ curl 'https://xxx.execute-api.ap-northeast-1.amazonaws.com/v1/users' \
--header 'Authorization: <アクセストークン>'

{"result":"hello"}

次に、POST /users へアクセスします。アクセストークン取得時に指定した scope の値が、読み取り専用だったため、401 エラーが返ってきます。

$ curl -X POST 'https://xxx.execute-api.ap-northeast-1.amazonaws.com/v1/users' \
--header 'Authorization: <アクセストークン>'

{ "message": "Unauthorized" }

再度、アクセストークンを取得します。今度は、全アクセス可能なアプリクライアントの認証情報を使用します。 scope の値は全アクセス(users/*)を指定します。

$ curl -X POST 'https://client-credentials-sample.auth.ap-northeast-1.amazoncognito.com/oauth2/token' \
--header 'Authorization: Basic <全アクセス可能なアプリクライアントの認証情報を Base64 エンコードした文字列>' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode 'scope=users/*'

{"access_token":"<アクセストークン>","expires_in":3600,"token_type":"Bearer"}

取得したアクセストークンを使って、作成した REST API の POST /users へアクセスします。リクエストが成功して、hello が返ってきます。

$ curl -X POST 'https://xxx.execute-api.ap-northeast-1.amazonaws.com/v1/users' \
--header 'Authorization: <アクセストークン>'

{"result":"hello"}

補足として、スコープに関係なくアクセストークンの有効期限が切れた場合は、401 エラーで、下記が返ってきます。

{ "message": "The incoming token has expired" }

おわりに

久々の CDK ネタでした。じゃんじゃんサンプル残せるように色々勉強します。

今回は以上になります。最後まで読んで頂きありがとうございました!