[AWS CDK] Cognito + API Gateway で M2M 認証をやってみた

Cognito + API Gateway による M2M 認証機構を AWS CDK を用いて作成して、実際に認証・認可が行えるか試してみました!
2022.04.01

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは!LINE 事業部のたにもんです!

Cognito + API Gateway による M2M 認証機構を AWS CDK を用いて作成して、実際に認証・認可が行えるか試してみました。 なお、この記事における M2M 認証は OAuth 2.0 の Client Credentials Grant を意味しています。

今回開発したソースコードはこちらのリポジトリで公開しているので、プロジェクト全体のソースコードを確認したい方はご参考にしてください。

構成図

構成図

環境

$ node --version
v16.14.0

$ npm --version
8.3.1

$ aws --version
aws-cli/2.4.29 Python/3.9.12 Darwin/21.4.0 source/arm64 prompt/off

$ cdk --version
2.18.0 (build 75c90fa)

$ jq --version
jq-1.6

そもそも M2M 認証ってなに?

M2M 認証とは Machine to Machine 認証の略で、機器間認証と訳されます。 M2M 認証が必要になるユースケースとしては、バックエンド API 間での認証などが挙げられるかと思います。

一般的な対人の認証・認可フローでは、メールアドレスとパスワードの組み合わせや、Google や Twitter, Facebook などのソーシャルログインを用いるのが一般的です。

ところが、バックエンド API 間の認証などにおいて、上記の対人と同様の認証・認可プロセスを行うのはあまり意味をなしません。

そこで登場するのが、M2M 認証です。 M2M 認証では、クライアント ID とクライアントシークレットのペアを用いて認証・認可を行うことができます。

さて、前置きが長くなってしまいましたが、以降では Cognito + API Gateway を用いた M2M 認証機構の実装方法の説明を行っていきます。

Lambda 関数の作成

まずは、認証・認可プロセスをパスしたアクセス権限を持つクライアントが実行する Lambda 関数を作成します。 以下の Lambda 関数を作成しました。 今回は、Cognito による M2M 認証にフォーカスしているため、Lambda 関数はメッセージを返すだけのシンプルな実装にしています。

lambda/hello.ts

import { APIGatewayProxyHandler } from 'aws-lambda';

export const handler: APIGatewayProxyHandler = async (_) => {
  return {
    statusCode: 200,
    isBase64Encoded: false,
    body: JSON.stringify({
      message: 'Hello World! You are successfully authorized with Cognito!',
    }),
  };
};

CDK で AWS リソースの作成

主に以下 3 種のリソースを作成します。

  • Cognito
  • API Gateway
  • Lambda

それぞれについて説明します。

Cognito

Cognito 関連のリソースとしては、まずユーザープールを作成し、このユーザープールに対して各種設定を加えていきます。 ユーザープールに追加する設定は以下のとおりです。

  • ユーザープールドメイン
    • ここで設定したドメインプレフィックスを持つドメインが利用可能になり、このドメイン配下の /oauth2/token エンドポイントにアクセスすることにより、アクセストークンの発行が行えるようになります
  • リソースサーバー
    • アクセス保護されたリソースを保持するサーバーを特定する識別子と、そのサーバーに対して行える操作を表すスコープを設定します
  • アプリクライアント
    • アクセス保護されたリソースに対してアクセスする主体を表します
    • M2M 認証を行うには、アプリクライアントに以下の設定を行う必要があります
    • generateSecret: true
      • M2M 認証にはクライアントシークレットが必要になるので、クライアントシークレットを生成するように設定します
    • oAuth.flows.clientCredentials: true
    • oAuth.scopes.scopeName: スコープ識別子

これらの設定を行ったユーザープールを作成する CDK のコードは以下のようになります。

const userPool = new cognito.UserPool(this, 'M2MAuthUserPool', {
  userPoolName: 'M2MAuthUserPool',
});

userPool.addDomain('M2MAuthCognitoDomain', {
  cognitoDomain: {
    domainPrefix: 'm2m-auth-sample-domain',
  },
});

const resourceServerId = 'example.com';
const scopeName = 'read';
const readScope = new cognito.ResourceServerScope({
  scopeName: scopeName,
  scopeDescription: 'Read access to the resource',
});
userPool.addResourceServer('M2MAuthResourceServer', {
  identifier: resourceServerId,
  scopes: [readScope],
});

const scopeId = `${resourceServerId}/${scopeName}`;
userPool.addClient('M2MAuthClient', {
  userPoolClientName: 'M2MAuthClient',
  generateSecret: true,
  oAuth: {
    flows: {
      clientCredentials: true,
    },
    scopes: [
      {
        scopeName: scopeId,
      },
    ],
  },
});

API Gateway

REST API リソースを作成し、その REST API に対して、先述したユーザープールによって認可を行う Cognito オーソライザーを設定します。

const api = new apigateway.RestApi(this, 'M2MAuthRestApi', {
  restApiName: 'M2MAuthRestApi',
});

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

Lambda

先述した Lambda 関数を実行するためのリソースを作成し、API Gateway の GET /hello エンドポイントで実行できるように設定します。 このエンドポイントにアクセスされた際に、認可が行われるよう、apigateway.Resource.addMethod メソッドのオプションとして、authorizationScopesauthorizer の設定を行っています。

const lambdaFunc = new lambda.Function(this, 'HelloFunc', {
  runtime: lambda.Runtime.NODEJS_14_X,
  code: lambda.Code.fromAsset('lambda'),
  handler: 'hello.handler',
});

const helloResource = api.root.addResource('hello');
helloResource.addMethod('GET', new apigateway.LambdaIntegration(lambdaFunc), {
  authorizationScopes: [scopeId],
  authorizer: {
    authorizationType: apigateway.AuthorizationType.COGNITO,
    authorizerId: authorizer.ref,
  },
});

CDK スタックの全体像

CDK スタック全体のソースコードは以下のようになります。

lib/cognito-m2m-auth-stack.ts

import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import { AuthorizationType } from 'aws-cdk-lib/aws-apigateway';

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

    const userPool = new cognito.UserPool(this, 'M2MAuthUserPool', {
      userPoolName: 'M2MAuthUserPool',
    });

    userPool.addDomain('M2MAuthCognitoDomain', {
      cognitoDomain: {
        domainPrefix: 'm2m-auth-sample-domain',
      },
    });

    const resourceServerId = 'example.com';
    const scopeName = 'read';
    const readScope = new cognito.ResourceServerScope({
      scopeName: scopeName,
      scopeDescription: 'Read access to the resource',
    });
    userPool.addResourceServer('M2MAuthResourceServer', {
      identifier: resourceServerId,
      scopes: [readScope],
    });

    const scopeId = `${resourceServerId}/${scopeName}`;
    userPool.addClient('M2MAuthClient', {
      userPoolClientName: 'M2MAuthClient',
      generateSecret: true,
      oAuth: {
        flows: {
          clientCredentials: true,
        },
        scopes: [
          {
            scopeName: scopeId,
          },
        ],
      },
    });

    const api = new apigateway.RestApi(this, 'M2MAuthRestApi', {
      restApiName: 'M2MAuthRestApi',
    });

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

    const lambdaFunc = new lambda.Function(this, 'HelloFunc', {
      runtime: lambda.Runtime.NODEJS_14_X,
      code: lambda.Code.fromAsset('lambda'),
      handler: 'hello.handler',
    });

    const helloResource = api.root.addResource('hello');
    helloResource.addMethod(
      'GET',
      new apigateway.LambdaIntegration(lambdaFunc),
      {
        authorizationScopes: [scopeId],
        authorizer: {
          authorizationType: apigateway.AuthorizationType.COGNITO,
          authorizerId: authorizer.ref,
        },
      }
    );
  }
}

動かしてみる

上記の CDK スタックをデプロイしたら、M2M 認証が行えるか実際に動かしてみましょう。

まずは下準備として、以下のような Shell 変数を定義する .env ファイルを作成してください。 各変数に設定すべき値は AWS のマネジメントコンソールから確認することができます。

.env

COGNITO_CLIENT_ID=<Cognito User Pool Client ID>
COGNITO_CLIENT_SECRET=<Cognito App Client Secret>
COGNITO_DOMAIN=<Cognito Domain>
API_ENDPOINT=<API Endpoint>

上記 .env ファイルと同一ディレクトリに、以下の test.sh を作成します。 これを実行することで、アクセストークンの発行と、認可情報の有無それぞれのパターンでの API へのアクセス結果を確認することができます。

test.sh

#!/usr/bin/env bash

BASE_DIR=$(dirname "$0")
source ${BASE_DIR}/.env

AUTH=$(echo -n ${COGNITO_CLIENT_ID}:${COGNITO_CLIENT_SECRET} | base64)
AUTH_SCOPE='example.com/read'
TOKEN_ENDPOINT="${COGNITO_DOMAIN}/oauth2/token"

echo 'Getting the access token...'

TOKEN=$(curl -sX POST \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -H "Authorization: Basic ${AUTH}" \
  -d 'grant_type=client_credentials' \
  -d "scope=${AUTH_SCOPE}" \
  ${TOKEN_ENDPOINT} \
  | jq -r '.access_token')

echo $'Done.\n'

API_PATH='/hello'
API_URL=${API_ENDPOINT}${API_PATH}

echo 'Access to the API without the token:'
curl ${API_URL}
echo -e '\n'

echo 'Access to the API with the token:'
curl -H "Authorization: Bearer ${TOKEN}" ${API_URL}

実際の実行結果は以下のようになりました。 アクセストークンを設定した場合のみ、Lambda 関数が正常に実行されていることが確認できますね!

$ ./test.sh
Getting the access token...
Done.

Access to the API without the token:
{"message":"Unauthorized"}

Access to the API with the token:
{"message":"Hello World! You are successfully authorized with Cognito!"}

参考