Amplifyを使ったソーシャルログインのバックエンドの設定

本記事は[AWS AmplifyとAWS×フロントエンド Advent Calendar 2022](https://qiita.com/advent-calendar/2022/amplify)の23日目の記事です。

# はじめに
今回は、Amplifyを使ったソーシャルログインのバックエンドの実装について、まとめていきたいと思います。

# 環境
AmplifyCLI 10.4.0

# 想定のゴール
* Amplifyを使ってソーシャルログインができる
    * facebook
    * LINE
    * Google
    * Yahoo
* 複数のidpを1つアカウントとして扱える(Qiitaと同じ感じの仕様)
    * どの方法(SNSでもCognitoのID/PWでも)でログインしても同じメールアドレスは同じアカウント扱いにしたい
    * ソーシャルログインした場合でも、後からパスワードの設定をすれば、Cognitoのみでのログインを可能にする
    * パスワード設定後であれば、ソーシャルログインの紐付け解除が可能
    * 各SNSとの連携の紐付け/解除ができる
* Cognito設定
    * メールアドレスでログイン
    * attributesはemailとnickname
* Cognitoの各種設定をGUIを使わずにAmplifyで管理ができる

# 実装内容
今回の記事では上記の全てはかけませんでしたが、上記の構成を実現するための各種設定をまとめていきたいと思います。

## SNS側の設定
SNS側の設定(アプリ登録とAppID/シークレットキーの取得など)については、今回の記事では割愛します。詳細は以下などを参考に、AppIDとシークレットキーを準備ください。

[AWS Amplify（Cognito）でGoogleソーシャルログインする](https://qiita.com/takeshi_hirosue/items/1c32e43816ea1d6a204d)
[AWS Amplify（Cognito）でLINEへソーシャルログインする](https://qiita.com/takeshi_hirosue/items/7de213590f6e9f9f6838)
[AmazonCognitoでGoogle/Facebook認証してトークンを取得する](https://qiita.com/is_ryo/items/a437225115b27e05c8ee)

## Cognitoの初期設定
まずは、Authを追加して、初期の設定を行っていきます。今回は、facebookの追加を例に進めていきます。

```console
$ amplify add auth
# identity poolに紐付けるfacebookのAppIDを指定
Enter your Facebook App ID for your identity pool:  xxxxxxxxxxxxxxxx

# OAuthの設定
 Do you want to use an OAuth flow? Yes
# わかりやすく管理したいなどの理由なければ、そのまま推奨の値で保存
 What domain name prefix do you want to use? xxxxxxxxxxxxxxxxxxxxxxxx
# テスト環境ではまずlocalhostを保存、本番環境では本番のURLを保存
 Enter your redirect signin URI: http://localhost:3000/
? Do you want to add another redirect signin URI No
 Enter your redirect signout URI: http://localhost:3000/
? Do you want to add another redirect signout URI No
# この選択肢はいつからか追加されましたが、とりあえずこっちを選択
 Select the OAuth flows enabled for this project. Authorization code grant
# SNS側から取得したいデータを選択
 Select the OAuth scopes enabled for this project. Email, Profile
# 設定するSNSを選択
 Select the social providers you want to configure for your user pool: Facebook
  
 You've opted to allow users to authenticate via Facebook.  If you haven't already, you'll need to go to https://develope
rs.facebook.com and create an App ID. 
 
# 再度FacebookのAppIDを入力
 Enter your Facebook App ID for your OAuth flow:  xxxxxxxxxxxxxxx
# FacebookのSecretKeyを入力
 Enter your Facebook App Secret for your OAuth flow:  xxxxxxxxxxxxxxxxxxxxxxxxxxx
# Cognitoで指定のトリガー作成を選択
? Do you want to configure Lambda Triggers for Cognito? Yes
? Which triggers do you want to enable for Cognito Pre 
? What functionality do you want to use for Pre Sign-up
 Create your own module
```

上記の設定を行うと`xxxxxxxxxxxxxxxxxPreSignup`という関数が自動生成されますので、この関数を編集していきます。

## Lambda関数の作成

先ほど作成したAmplifyで自動生成したPreSignUpの関数を使って、複数のidpを1つアカウントとして扱うための処理を作成していきます。

本当はこの関数1つで実装を完結させたいのですが、上記のフローで生成された関数にCognitoのアクセス権限をつけるとエラーが発生するため、Cognitoへのアクセスについては別関数で対応し、その関数をPreSignUpから呼び出す方式で実装していきます。

[Trying to use model within post-confirmation function #4568](https://github.com/aws-amplify/amplify-cli/issues/4568)

PreSignUp関数を編集する前に、Cognitoへのアクセス権限を持つ関数をまず作成します。

```console
$ amplify add function

Select which capability you want to add: Lambda fun
ction (serverless function)
? Provide an AWS Lambda function name: operateCognito
? Choose the runtime that you want to use: NodeJS
? Choose the function template that you want to use: 
Hello World
Available advanced settings:
- Resource access permissions
- Scheduled recurring invocation
- Lambda layers configuration
- Environment variables configuration
- Secret values configuration

# ここからで細かい設定
? Do you want to configure advanced settings? Yes
? Do you want to access other resources in this proje
ct from your Lambda function? Yes

# authを選択
? Select the categories you want this function to hav
e access to. auth
# アクセスしたいリソースを選択
? Auth has 2 resources in this project. Select the on
e you would like your Lambda to access <アクセスしたいリソース>
# Cognitoへのアクセス権限を付与　
? Select the operations you want to permit on <アクセスしたいリソース> create, read, update, delete


You can access the following resource attributes as environment variables from your Lambda function
        AUTH_XXXXXXXXXXXXXXXXXXXX_USERPOOLID
        ENV
        REGION
? Do you want to invoke this function on a recurring 
schedule? No
? Do you want to enable Lambda layers for this functi
on? No
? Do you want to configure environment variables for 
this function? No
? Do you want to configure secret values this functio
n can access? No
? Do you want to edit the local lambda function now? 
No
```

上記で作成したLambda関数を編集していきます。


```js:operateCognito/src/index.js
/* Amplify Params - DO NOT EDIT
	AUTH_XXXXXXXXXXXXXXXX_USERPOOLID
	ENV
	REGION
Amplify Params - DO NOT EDIT */

/**
 * @type {import('@types/aws-lambda').APIGatewayProxyHandler}
 */

'use strict'
/* モジュール */
const AWS = require('aws-sdk')
const CognitoProvider = new AWS.CognitoIdentityServiceProvider()
const Crypto = require('crypto')

exports.handler = async (event) => {
  try {
    const data = event.arguments.input
    if (data.type === 'listUsers') {
      return await listUsers(data.params)
    } else if (data.type === 'createUser') {
      return await adminCreateUser(data.params)
    } else if (data.type === 'setUserPassword') {
      return await adminSetUserPassword(data.params)
    } else if (data.type === 'linkProvider') {
      return await adminLinkProviderForUser(data.params)
    }
  } catch (e) {
    console.log(e)
  }
}

// =======================
// Cognito関数群
// =======================
// 対象のユーザー一覧を取得
const listUsers = async (params) => {
  // https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_ListUsers.html
  const userList = await CognitoProvider.listUsers(params).promise()
  return userList
}
// ユーザー作成
const adminCreateUser = async (params) => {
  // https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_AdminCreateUser.html
  const user = await CognitoProvider.adminCreateUser(params).promise()
  return user
}
// パスワード強制変更状態から確認ステータスに変更およびパスワードを固定
const adminSetUserPassword = async (params) => {
  //  https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_AdminSetUserPassword.html
  params.Password = Crypto.randomBytes(40).toString('hex') // 暫定パスワード生成
  const user = await CognitoProvider.adminSetUserPassword(params).promise()
  return user
}
// パスワード強制変更状態から確認ステータスに変更およびパスワードを固定
const adminLinkProviderForUser = async (params) => {
  // https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_AdminLinkProviderForUser.html
  const user = await CognitoProvider.adminLinkProviderForUser(params).promise()
  return user
}
```

続いて、preSignUp関数に上記で作成した関数への呼び出し権限を付与します。

```console
$ amplify function update

? Select which capability you want to update: Lambda function (serverless function)
? Select the Lambda function you want to update xxxxxPreSignup

? Which setting do you want to update? Resource access permissions
? Select the categories you want this function to have access to. function
? Function has 47 resources in this project. Select the one you would like your Lambda to access operateCognito
? Select the operations you want to permit on operateCognito read

You can access the following resource attributes as environment variables from your Lambda function
        FUNCTION_OPERATECOGNITO_NAME
? Do you want to edit the local lambda function now? No
```

Cognito用関数の呼び出し権限を付けたら、PreSignUpの関数を編集していきます。

```js:xxxPreSignUp/src/custom.js

/**
 * @type {import('@types/aws-lambda').APIGatewayProxyHandler}
 */

'use strict'
/* モジュール */
const AWS = require('aws-sdk')
const Lambda = new AWS.Lambda()

// このcontextは使っていなけれど、callback関数を使うために必要なので消さない
exports.handler = async (event, context, callback) => {
  try {
    // データ準備
    const triggerSource = event.triggerSource
    const userPoolId = event.userPoolId
    const email = event.request.userAttributes.email
    let userId = event.userName
    const userName = event.request.userAttributes.nickname || userId

    // １） 登録処理判定
    // すでにCognitoアカウント作成済み（サインアップ未完了の再サインアップ処理やSNSサインアップ時はこの関数が2回実行される）との場合の回避策。
    // + (isAdminCreate)SNSサインアップでのAdmin権限でのユーザー作成時もトリガー走るため実行しない
    const user = await getCognitoUser(userPoolId, userId)
    const isAdminCreate = triggerSource === 'PreSignUp_AdminCreateUser' // この関数経由でのCognitoユーザー生成判定
    if (user || isAdminCreate) return callback(null, event)

    // ２） SNSでのサインアップ時の処理
    const isSNS = triggerSource === 'PreSignUp_ExternalProvider' // SNSでのサインアップ判定
    if (isSNS) {
      userId = await processExternalProvider(
        userPoolId,
        userId,
        email,
        userName
      )
    }
    
    // 3) 以下にDB作成処理などやりたい処理あれば記載

  } catch (e) {
    console.log(e)
  }
  // cognitoでのuser作成トリガーで実行するには、返り値が必ずいるらしい。https://dev.classmethod.jp/cloud/aws/cognito-trigger-lambda-python-login/
  callback(null, event)
}

// =====================
// メイン関数
// =====================
// SNSでのサインアップ時の処理
const processExternalProvider = async (userPoolId, userId, email, userName) => {
  // Conitoユーザープールから特定のメールアドレスをもつユーザー一覧取得
  const listData = await cognitoListUsers(userPoolId, email)

  // データ準備
  const [_providerName, providerUserId] = userId.split('_')
  const providerName =
    _providerName.charAt(0).toUpperCase() + _providerName.slice(1)
  const linkParams = {
    SourceUser: {
      ProviderAttributeName: 'Cognito_Subject',
      ProviderAttributeValue: providerUserId,
      ProviderName: providerName,
    },
    UserPoolId: userPoolId,
  }

  let newUserId = ''
  if (listData && listData.Users.length > 0) {
    /* 対象のメールアドレスのユーザーが存在する場合 */
    // 紐付け用データ整形
    linkParams.DestinationUser = {
      ProviderAttributeValue: listData.Users[0].Username,
      ProviderName: 'Cognito',
    }
    newUserId = listData.Users[0].Username
  } else {
    /* 対象のメールアドレスのユーザーが存在しない場合 */
    // Cognitoユーザー作成
    const createData = await cognitoCreateUser(userPoolId, email, userName)
    const cognitoUserId = createData.User.Username

    // パスワードの強制設定
    await cognitoSetUserPassword(userPoolId, cognitoUserId)

    // 紐付け用データ整形
    linkParams.DestinationUser = {
      ProviderAttributeValue: cognitoUserId,
      ProviderName: 'Cognito',
    }
    newUserId = cognitoUserId
  }
  // Cogito用アカウントにidp情報の紐付け処理
  await cognitoLinkProviderForUser(linkParams)
  return newUserId
}
// ユーザーデータ取得
const getCognitoUser = async (userPoolId, userId) => {
  const type = 'getUser'
  const params = {
    UserPoolId: userPoolId,
    Username: userId,
  }
  const user = await callOperateCognito(type, params)
  return user
}


// =====================
// サブ関数
// =====================

// Conitoユーザープールから特定のメールアドレスをもつユーザー一覧取得
const cognitoListUsers = async (userPoolId, email) => {
  const type = 'listUsers'
  const params = {
    UserPoolId: userPoolId,
    Filter: `email = "${email}"`,
  }
  const listData = await callOperateCognito(type, params)
  return listData
}
// Cognitoユーザー作成
const cognitoCreateUser = async (userPoolId, email, userName) => {
  const type = 'createUser'
  const params = {
    UserPoolId: userPoolId,
    Username: email,
    MessageAction: 'SUPPRESS',
    UserAttributes: [
      {
        Name: 'nickname',
        Value: userName,
      },
      {
        Name: 'email',
        Value: email,
      },
      {
        Name: 'email_verified',
        Value: 'true',
      },
    ],
  }
  const user = await callOperateCognito(type, params)
  return user
}
// パスワードの強制設定
const cognitoSetUserPassword = async (userPoolId, userId) => {
  const type = 'setUserPassword'
  const params = {
    UserPoolId: userPoolId,
    Username: userId,
    // Password: Crypto.randomBytes(40).toString('hex'), // パスワードのログは残したくないので、operateCognitoで作成
    Permanent: true,
  }
  const user = await callOperateCognito(type, params)
  return user
}
// Cogito用アカウントにidp情報の紐付け処理
const cognitoLinkProviderForUser = async (params) => {
  const type = 'linkProvider'
  await callOperateCognito(type, params)
}

// =====================
// 共通関数
// =====================

// OperateCognito関数を呼ぶ
const callOperateCognito = async (type, params) => {
  // データ整形
  const _data = {
    input: {
      type,
      params,
    },
  }
  const _payload = JSON.stringify({ arguments: _data })
  // params整形
  const lambdaParams = {
    FunctionName: process.env.FUNCTION_OPERATECOGNITO_NAME, // 呼び出す関数名
    InvocationType: 'RequestResponse', // 呼び出し方法
    Payload: _payload, // 引数(eventに渡される)
  }
  // Lambda関数実行
  const result = await Lambda.invoke(lambdaParams).promise()
  // 結果判定
  if (result.FunctionError) throw new Error(`===== ${type}/失敗 =====`)
  else console.log(`===== ${type}成功 =====`)
  return JSON.parse(result.Payload)
}

```

また、今回は、ユーザー名もSNS側から取得したいので、`amplify/auth/xxx/cli-inputs.json`の`hostedUIProviderMeta`に取得したい情報を追記します。

```json:amplify/auth/xxx/cli-inputs.json
{
  "version": "1",
  "cognitoConfig": {
    ...
    "authProvidersUserPool": ["Facebook"],
    "hostedUIProviderMeta": "[{\"ProviderName\":\"Facebook\",\"authorize_scopes\":\"email,public_profile\",\"AttributeMapping\":{\"email\":\"email\",\"username\":\"id\",\"nickname\":\"name\"}}]",
    "facebookAppId": "xxxxxxxxxxxxxxxx",
    "oAuthMetadata": "{\"AllowedOAuthFlows\":[\"code\"],\"AllowedOAuthScopes\":[\"email\",\"openid\",\"profile\"],\"CallbackURLs\":[\"http://localhost:3000/\"],\"LogoutURLs\":[\"http://localhost:3000/\"]}"
  }
}
```

ここまで設定が終わったら`$ amplify push`します。

フロント側の実装については今回書きませんが、aws-amplifyを使ってSNSログイン(`await Auth.federatedSignIn({ provider: 'Facebook' })`)を行い、最終的に以下のようにCognitoのアカウントが作成されれば成功です。

![スクリーンショット 2022-12-22 20.37.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252427/372c8473-3f1f-35d4-ca47-5e2d54acd4f1.png)

正しく動作すれば、SNSログインするとfacebookのCognitoアカウントではなく、サインアップ時に一緒に生成されたもう一つのCognitoアカウント(上記の画像で言う`d627f...`)の認証情報をベースにやり取りができるようになります。

本当は、1つのCognitoアカウントでできるのが理想ですが、現状この方法がCognitoにおけるベターな方法のようです。

# 豆知識
## SNSログイン中のCognito情報の取得
ログイン中のCognito情報を取得したい場合は、AuthのAPIを利用して取得しますが、SNSログイン中は、通常と少し違うので注意が必要です。
SNSログイン中に、Cognitoのユーザー名を取得したい時は、`currentAuthenticatedUser`で取得します。同じようなAPIで`currentUserInfo`がありますが、こちらの返り値は空になります。

## HubのsignInは呼ばれない
これは私自身の場合ですが、Amplifyを使ったログインにおいて、以下のようにHubを使って、SignIn時の処理を行っています。

```js
import { Hub } from 'aws-amplify'
Hub.listen('auth', async (data) => {
  switch (data.payload.event) {
    case 'signIn': {
      // 処理
    }
  }
})
```
上記の実装で、通常のログイン時は問題なく動きますが、ソーシャルログインをした時、このHubは動かないため、個別でのログイン処理を実装する必要があります。

# SNS設定時のメモ
## Facebook
* テスト用と本番用のAPPを作成
* APIのバージョンは15を選択
* 有効なOAuthリダイレクトURI
    * Cognito ドメイン + `/oauth2/idpresponse`


# おわりに
今回想定したゴールの内容を実運用するには、もう少し作らないといけないバックエンドもありますが、ベースはこんな形になります。

数年前までプログラミングを何も知らなかった私がバックエンドの開発を行えているのは、Amplifyのおかげと言っても過言ではございません。感謝の気持を込めて、少しでも還元し、この記事が誰かのお役に立てば幸いです！

# 参考記事
[AWS CLIで動かして学ぶCognito IDプールを利用したAWSの一時クレデンシャルキー発行](https://dev.classmethod.jp/articles/get-aws-temporary-security-credentials-with-cognito-id-pool-by-aws-cli/)
[Cognitoで複数のIDP認証を単一ユーザー認証として扱うベストプラクティスを考えてみました🤔](https://www.ragate.co.jp/blog/articles/3826)
[AWS Cognito: Best practice to handle same user (with same email address) signing in from different identity providers (Google, Facebook)](https://stackoverflow.com/questions/59635482/aws-cognito-best-practice-to-handle-same-user-with-same-email-address-signing?rq=1)
[Amazon Cognito で複数の Idp を使って1人のユーザーを認証する](https://tseki.hatenablog.com/entry/2021/06/20/013646)