こんにちは!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
- Client Credential Grant (本記事における M2M 認証) を行うための設定です
oAuth.scopes.scopeName: スコープ識別子
- このアプリクライアントがリソースサーバーに対して実行可能な操作を表すスコープ識別子を指定します
- スコープ識別子は
リソースサーバー識別子/スコープ名
の形式を取ります - 参考: ユーザープールのリソースサーバーを定義する - AWS デベロッパーガイド
これらの設定を行ったユーザープールを作成する 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
メソッドのオプションとして、authorizationScopes
と authorizer
の設定を行っています。
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!"}