AWS Amplify の Admin Queries API にユーザーを作成・更新・削除する機能が提供されていないので追加してみた

はじめに

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

AWS Amplify の Admin Queries API って Cognito のユーザープール周りを管理する API を簡単に作れちゃうので、めちゃ便利ですよね。

めちゃ便利なのですが、ユーザーを作成したり、更新したり、削除したりな機能が提供されていないんですよね。これらの機能が欲しいな〜と思い、どうしようかな、う〜ん、自分で追加しちゃえば良いじゃんってことで試してみました。

ちなみに最初から提供されている機能は下記になります。

  • addUserToGroup
  • removeUserFromGroup
  • confirmUserSignUp
  • disableUser
  • enableUser
  • getUser
  • listUsers
  • listGroups
  • listGroupsForUser
  • listUsersInGroup
  • signUserOut

詳細については下記のドキュメントを確認してください。

環境

環境は下記の通りです。

$ sw_vers
ProductName:	Mac OS X
ProductVersion:	10.15.7
BuildVersion:	19H2

$ amplify --version
4.48.0

Admin Queries API を作成する

既にamplify configureamplify initが実行されていることを前提とします。

対話形式で認証周りのリソースを作成していきます。

$ amplify add auth
Using service: Cognito, provided by: awscloudformation
 
 The current configured provider is Amazon Cognito.

Admin Queries API を作成するため、Manual configurationを選択して各種設定を行います。

 Do you want to use the default authentication and security configuration? Manual configuration
 Select the authentication/authorization services that you want to use: User Sign-Up & Sign-In only (Best used with a cloud API on
ly)
 Please provide a friendly name for your resource that will be used to label this category in the project: amplifysample
 Please provide a name for your user pool: amplify-sample-user-pool
 Warning: you will not be able to edit these selections. 
 How do you want users to be able to sign in? Username

ユーザープールにAdminsというグループを作成します。このグループに属するユーザーのみ、Admin Queries API へアクセスできるような設定を行います。

 Do you want to add User Pool Groups? Yes
? Provide a name for your user pool group: Admins
? Do you want to add another User Pool Group No
✔ Sort the user pool groups in order of preference · Admins
 Do you want to add an admin queries API? Yes
? Do you want to restrict access to the admin queries API to a specific Group Yes
? Select the group to restrict access with: Admins

以降の設定はお好みで。

 Multifactor authentication (MFA) user login options: OFF
 Email based user registration/forgot password: Enabled (Requires per-user email entry at registration)
 Please specify an email verification subject: Your verification code
 Please specify an email verification message: Your verification code is {####}
 Do you want to override the default password policy for this User Pool? No
 Warning: you will not be able to edit these selections. 
 What attributes are required for signing up? Email
 Specify the app's refresh token expiration period (in days): 30
 Do you want to specify the user attributes this app can read and write? No
 Do you want to enable any of the following capabilities? 
 Do you want to use an OAuth flow? No
? Do you want to configure Lambda Triggers for Cognito? No
Successfully added AdminQueriesd859db70 function locally
Successfully added AdminQueries API locally
Successfully added auth resource amplifysample locally

Some next steps:
"amplify push" will build all your local backend resources and provision it in the cloud
"amplify publish" will build all your local backend and frontend resources (if you have hosting category added) and provision it in the cloud

デプロイします。

$ amplify push
✔ Successfully pulled backend environment dev from the cloud.

Current Environment: dev

| Category | Resource name        | Operation | Provider plugin   |
| -------- | -------------------- | --------- | ----------------- |
| Auth     | userPoolGroups       | Create    | awscloudformation |
| Auth     | amplifysample        | Create    | awscloudformation |
| Function | AdminQueriesd859db70 | Create    | awscloudformation |
| Api      | AdminQueries         | Create    | awscloudformation |
? Are you sure you want to continue? Yes
⠴ Updating resources in the cloud. This may take a few minutes...

...

UPDATE_COMPLETE_CLEANUP_IN_PROGRESS amplify-amplifysample-dev-04506 AWS::CloudFormation::Stack Sat May 08 2021 01:16:49 GMT+0900 (日本標準時) 
UPDATE_COMPLETE                     amplify-amplifysample-dev-04506 AWS::CloudFormation::Stack Sat May 08 2021 01:16:49 GMT+0900 (日本標準時) 
✔ All resources are updated in the cloud

REST API endpoint: https://xxx.execute-api.ap-northeast-1.amazonaws.com/dev

マネコンで API Gateway のページを開くと、AdminQueriesという名前の API が作成されていることを確認できます。

Admin Queries API の実装を確認する

Admin Queries API の主な実装は amplify/backend/function/AdminQueriesXXX/src/ の中にある app.jscognitoActions.js に記載されています。

app.jsの中に、追加する機能のパスを登録してあげれば良さそうです。

app.js

const express = require('express');
const bodyParser = require('body-parser');
const awsServerlessExpressMiddleware = require('aws-serverless-express/middleware');

const {
  addUserToGroup,
  removeUserFromGroup,
  confirmUserSignUp,
  disableUser,
  enableUser,
  getUser,
  listUsers,
  listGroups,
  listGroupsForUser,
  listUsersInGroup,
  signUserOut,
} = require('./cognitoActions');

const app = express();
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(awsServerlessExpressMiddleware.eventContext());

...

app.get('/getUser', async (req, res, next) => {
  if (!req.query.username) {
    const err = new Error('username is required');
    err.statusCode = 400;
    return next(err);
  }

  try {
    const response = await getUser(req.query.username);
    res.status(200).json(response);
  } catch (err) {
    next(err);
  }
});

app.get('/listUsers', async (req, res, next) => {
  try {
    let response;
    if (req.query.token) {
      response = await listUsers(req.query.limit || 25, req.query.token);
    } else if (req.query.limit) {
      response = await listUsers((Limit = req.query.limit));
    } else {
      response = await listUsers();
    }
    res.status(200).json(response);
  } catch (err) {
    next(err);
  }
});

...

app.listen(3000, () => {
  console.log('App started');
});

module.exports = app;

実際に aws-sdk を呼び出す実装は、cognitoActions.js 内で作れば良さそうです。

cognitoActions.js

const { CognitoIdentityServiceProvider } = require('aws-sdk');

const cognitoIdentityServiceProvider = new CognitoIdentityServiceProvider();
const userPoolId = process.env.USERPOOL;

...

async function getUser(username) {
  const params = {
    UserPoolId: userPoolId,
    Username: username,
  };

  console.log(`Attempting to retrieve information for ${username}`);

  try {
    const result = await cognitoIdentityServiceProvider.adminGetUser(params).promise();
    return result;
  } catch (err) {
    console.log(err);
    throw err;
  }
}

async function listUsers(Limit, PaginationToken) {
  const params = {
    UserPoolId: userPoolId,
    ...(Limit && { Limit }),
    ...(PaginationToken && { PaginationToken }),
  };

  console.log('Attempting to list users');

  try {
    const result = await cognitoIdentityServiceProvider.listUsers(params).promise();

    // Rename to NextToken for consistency with other Cognito APIs
    result.NextToken = result.PaginationToken;
    delete result.PaginationToken;

    return result;
  } catch (err) {
    console.log(err);
    throw err;
  }
}

...

module.exports = {
  addUserToGroup,
  removeUserFromGroup,
  confirmUserSignUp,
  disableUser,
  enableUser,
  getUser,
  listUsers,
  listGroups,
  listGroupsForUser,
  listUsersInGroup,
  signUserOut,
};

機能を追加する

ユーザーを作成・更新・削除する機能を追加します。

cognitoActions.js

...

// ★ ユーザー作成処理
async function createUser(username, userAttributes) {
  const params = {
    UserPoolId: userPoolId,
    Username: username,
    UserAttributes: userAttributes,
  };

  console.log(`Attempting to create user ${username}`);

  try {
    const result = await cognitoIdentityServiceProvider
      .adminCreateUser(params)
      .promise();

    return result;
  } catch (err) {
    console.log(err);
    throw err;
  }
}

// ★ ユーザー更新処理
async function updateUser(username, userAttributes) {
  const params = {
    UserPoolId: userPoolId,
    Username: username,
    UserAttributes: userAttributes,
  };

  console.log(`Attempting to update user ${username}`);

  try {
    const result = await cognitoIdentityServiceProvider
      .adminUpdateUserAttributes(params)
      .promise();

    return result;
  } catch (err) {
    console.log(err);
    throw err;
  }
}

// ★ ユーザー削除処理
async function deleteUser(username) {
  const params = {
    UserPoolId: userPoolId,
    Username: username,
  };

  console.log(`Attempting to delete user ${username}`);

  try {
    const result = await cognitoIdentityServiceProvider
      .adminDeleteUser(params)
      .promise();

    return result;
  } catch (err) {
    console.log(err);
    throw err;
  }
}

module.exports = {
  addUserToGroup,
  removeUserFromGroup,
  confirmUserSignUp,
  disableUser,
  enableUser,
  getUser,
  listUsers,
  listGroups,
  listGroupsForUser,
  listUsersInGroup,
  signUserOut,
  // ★ exports の追加も忘れずに
  createUser,
  updateUser,
  deleteUser,
};

app.js

...

const {
  addUserToGroup,
  removeUserFromGroup,
  confirmUserSignUp,
  disableUser,
  enableUser,
  getUser,
  listUsers,
  listGroups,
  listGroupsForUser,
  listUsersInGroup,
  signUserOut,
  // ★ cognitoActions.js で追加した関数を持ってくる
  createUser,
  updateUser,
  deleteUser,
} = require('./cognitoActions');

...

// ★ ユーザー作成用のパス
app.post('/createUser', async (req, res, next) => {
  if (!req.body.username) {
    const err = new Error('username is required');
    err.statusCode = 400;
    return next(err);
  }

  try {
    const response = await createUser(
      req.body.username,
      req.body.userAttributes
    );
    res.status(200).json(response);
  } catch (err) {
    next(err);
  }
});

// ★ ユーザー更新用のパス
app.post('/updateUser', async (req, res, next) => {
  if (!req.body.username) {
    const err = new Error('username is required');
    err.statusCode = 400;
    return next(err);
  }

  if (!req.body.userAttributes) {
    const err = new Error('userAttributes is required');
    err.statusCode = 400;
    return next(err);
  }

  try {
    const response = await updateUser(
      req.body.username,
      req.body.userAttributes
    );
    res.status(200).json(response);
  } catch (err) {
    next(err);
  }
});

// ★ ユーザー削除用のパス
app.post('/deleteUser', async (req, res, next) => {
  if (!req.body.username) {
    const err = new Error('username is required');
    err.statusCode = 400;
    return next(err);
  }

  try {
    const response = await deleteUser(req.body.username);
    res.status(200).json(response);
  } catch (err) {
    next(err);
  }
});

...

Lambda の実行ロールに、ユーザーを作成・更新・削除する権限を付与します。

amplify/backend/function/AdminQueriesXXX/AdminQueriesXXX-cloudformation-template.json

    ...
    
    "lambdaexecutionpolicy": {
      "DependsOn": ["LambdaExecutionRole"],
      "Type": "AWS::IAM::Policy",
      "Properties": {
        "PolicyName": "lambda-execution-policy",
        "Roles": [
          {
            "Ref": "LambdaExecutionRole"
          }
        ],
        "PolicyDocument": {
          "Version": "2012-10-17",
          "Statement": [
            {
              "Effect": "Allow",
              "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
              ],
              "Resource": {
                "Fn::Sub": [
                  "arn:aws:logs:${region}:${account}:log-group:/aws/lambda/${lambda}:log-stream:*",
                  {
                    "region": {
                      "Ref": "AWS::Region"
                    },
                    "account": {
                      "Ref": "AWS::AccountId"
                    },
                    "lambda": {
                      "Ref": "LambdaFunction"
                    }
                  }
                ]
              }
            },
            {
              "Effect": "Allow",
              "Action": [
                "cognito-idp:ListUsersInGroup",
                "cognito-idp:AdminUserGlobalSignOut",
                "cognito-idp:AdminEnableUser",
                "cognito-idp:AdminDisableUser",
                "cognito-idp:AdminRemoveUserFromGroup",
                "cognito-idp:AdminAddUserToGroup",
                "cognito-idp:AdminListGroupsForUser",
                "cognito-idp:AdminGetUser",
                "cognito-idp:AdminConfirmSignUp",
                "cognito-idp:ListUsers",
                "cognito-idp:ListGroups",
                "cognito-idp:AdminCreateUser",
                "cognito-idp:AdminUpdateUserAttributes",
                "cognito-idp:AdminDeleteUser"
              ],
              
              ...

実装できたら、amplify push で再デプロイしてください。

フロントエンドからの呼び出し方

下記のドキュメントを参考にして、追加した機能をフロントエンドから呼び出す例を記載します。尚、アクセス権限があるのは、Adminsグループに属しているユーザーのみです。

Amplify Docs - Admin Queries API - Example

ユーザーを作成・更新・削除する関数を実装します。

import { Auth, API } from 'aws-amplify';

type UserAttribute = {
  Name: string;
  Value: string;
}

const createUser = async (username: string, userAttributes?: UserAttribute[]) => {
  const apiName = 'AdminQueries';
  const path = '/createUser';
  const params = {
    headers: {
      'Content-Type': 'application/json',
      Authorization: `${(await Auth.currentSession())
        .getAccessToken()
        .getJwtToken()}`,
    },
    body: {
      username,
      userAttributes,
    },
  };
  return await API.post(apiName, path, params);
};

const updateUser = async (username: string, userAttributes?: UserAttribute[]) => {
  const apiName = 'AdminQueries';
  const path = '/updateUser';
  const params = {
    headers: {
      'Content-Type': 'application/json',
      Authorization: `${(await Auth.currentSession())
        .getAccessToken()
        .getJwtToken()}`,
    },
    body: {
      username,
      userAttributes,
    },
  };
  return await API.post(apiName, path, params);
};

const deleteUser = async (username: string) => {
  const apiName = 'AdminQueries';
  const path = '/deleteUser';
  const params = {
    headers: {
      'Content-Type': 'application/json',
      Authorization: `${(await Auth.currentSession())
        .getAccessToken()
        .getJwtToken()}`,
    },
    body: { username },
  };
  return await API.post(apiName, path, params);
};

ユーザーを作成する場合、実装した関数を下記のように呼び出します。

...

const attrs = [
  { Name: 'email', Value: email },
  { Name: 'address', Value: address },
  { Name: 'phone_number', Value: `+81${phoneNumber}` },
  { Name: 'birthdate', Value: birthdate },
  { Name: 'email_verified', Value: 'true' },
  { Name: 'phone_number_verified', Value: 'true' },
];

await createUser(username, attrs);

ユーザーの更新・削除も同じように呼び出せば OK です。

おわりに

必要に応じて他の機能も追加することができそうです。やったぜ。

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