tomoima525's blog

Androidとか技術とかその他気になったことを書いているブログ。世界の秘密はカレーの中にある!サンフランシスコから発信中。

パスワードレス認証サービスMagicとAmplify(Cognito)を使った認証システム

これは AWS Amplify Advent Calendar 2021 https://qiita.com/advent-calendar/2021/amplify 20日目の記事です。

Magic は Email や今はやりの web3 におけるパスワードレス認証を実現するためのツールです。

magic.link

f:id:tomoima525:20211220173439g:plain
Magicのパスワードレス認証

AWS Amplify は Amazon Cognito を使用した認証機能をビルドインでサポートしているため、Magic のようなパスワードレス認証を導入したい場合、コードベースにいくつかのカスタマイズを行う必要があります。

今回は、Amplify の Web アプリケーションにこの Magic を統合し、Email を利用したパスワードレス認証をする方法を紹介します。もしコードをまず見たいということであれば、こちらへどうぞ。

github.com

認証の流れ

全体の認証の流れを簡単に説明すると、こんな感じです。簡単に説明します。

f:id:tomoima525:20211220175051p:plain

  • ユーザがメールアドレスを入力すると、メールで Magic から認証情報を受け取ります
  • コールバックとして受け取った didtokenissuer id を使って、クライアントアプリケーションは API 経由で Lambda 関数を呼び出して認証を行います。
  • バックエンドの Lambda 関数は Cognito Federated Identity にアクセスし、OpenID トークン (TokenIdentity) をクライアント側に返します。
  • クライアントアプリケーションは、OpenID にサインアップします。Cognito Federated Idenitity は、ユーザーが AWS サービスにアクセスすることを承認します。

また、Amplify のセッションは 1 時間ごとに期限が切れます。Amplify はデフォルト認証の場合、トークンを自動的にリフレッシュしますが、Custom Federated Provider の場合は期限が切れるたびにトークンを自分で更新する必要があります。*1

それぞれの実装を説明する前に、Amazon Cognito の認証処理の概念とツールについて触れておきます。

Amazon Cognito

Amazon Cognito は、AWS のサービスにアクセスするための認証(サインイン、サインアップ)と認可を提供するツールです。主に 2 つの機能があります。

Cognito User Pool

Cognito Federated Identity Pools

  • ユーザーが AWS サービスを利用するための権限を付与するためのツールです。認証されたユーザーのアクセスを制御するためのアクセストークンを提供します。

デフォルトでは、Amplify は両方の機能を使用します。しかし、Cognito User Pools は従来のメール/パスワードか、GoogleFacebook などのソーシャルプロバイダーしかサポートしていないため、パスワードレス認証を導入する場合利用することができません。そこで登場するのが Developer Authenticated Identities です。

Developer authenticated identities

Developer Authenticated Identities は、認証プロセスに Custom Provider(例えば自前の認証システム) を利用しつつ、Cognito がユーザーデータと AWS サービスへのアクセスを管理することを可能にします。具体的には、サードパーティから提供された認証 ID を使用して OpenID Connect トークンを取得します。

つまり、Magic を ID プロバイダとして利用することで、Developer Authenticated Identities からユーザー認証とアクセストークンの取得が可能になるわけです。

Developer 認証 ID や OpenID Connect についてもっと詳しく知りたい方は、以下のドキュメントをご覧ください。

docs.aws.amazon.com

docs.aws.amazon.com

バックエンドの実装

では、実際の実装を見ていきます。ここでは、プロジェクトにAmplify をセットアップし*2、Magic で Api キーを生成済みとします。以下のステップを踏んでいきます。

  • ステップ 1. Amplify Auth のセットアップ
  • ステップ 2. Cognito Federated Identity のセットアップ
  • ステップ 3. OpenID Connect トークンを取得するための Lambda 関数を実装する
  • ステップ 4. API ゲートウェイを追加する

Step1. Amplify Auth のセットアップ

まずは Amplify を使った認証の実装からです。

$ amplify add auth
 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, connected with AWS IAM controls (Enables per-user Storage features for images or other content, Analy
tics, and more)
 Provide a friendly name for your resource that will be used to label this category in the project: magicAuth
 Enter a name for your identity pool. magic33015299_identitypool_33015299
 Allow unauthenticated logins? (Provides scoped down permissions that you can control via AWS IAM) Yes
 Do you want to enable 3rd party authentication providers in your identity pool? No
 Provide a name for your user pool: magic33015299_userpool_33015299
 Warning: you will not be able to edit these selections.
 How do you want users to be able to sign in? Email
 Do you want to add User Pool Groups? No
 Do you want to add an admin queries API? No
 Multifactor authentication (MFA) user login options: OFF
 Email based user registration/forgot password: Enabled (Requires per-user email entry at registration)
 Specify an email verification subject: Your verification code
 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 auth resource magicAuth locally

ここでは、User Pool とサインインのための詳細設定します。この設定を amplify push -y でプッシュします。

Step2: Cognito Federated Identity をセットアップする

Cognito Federated Identity にカスタムの認証プロバイダを追加します。AWS ウェブサイトを開き、Amazon Cognito Console に移動し、Federated Identities セクションをクリックします。

f:id:tomoima525:20211220172048p:plain

"Edit identity pool" を押して、Authentication providers を探し、Custom タブで識別子名を追加してください。忘れずに変更を保存してください。識別子は何でも良いですが、ユニークである必要があります。後ほど使用します。ここではわかりやすく "com.magic.link" としました。

f:id:tomoima525:20211220172105p:plain

Step3: OpenID Connect トークンを取得するための Lambda 関数を実装する

次に Magic が生成した did の有効性をチェックし、OpenId Connect Token を取得する Lambda 関数を作成します。

$ amplify add function
? Select which capability you want to add: Lambda function (serverless function)
? Provide an AWS Lambda function name: magicAuthentication
? 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 project from your Lambda function? No
? Do you want to invoke this function on a recurring schedule? No
? Do you want to enable Lambda layers for this function? No
? Do you want to configure environment variables for this function? No
? Do you want to configure secret values this function can access? Yes
? Enter a secret name (this is the key used to look up the secret value): MAGIC_PUB_KEY
? Enter the value for MAGIC_PUB_KEY: [hidden]
? What do you want to do? I'm done
Use the AWS SSM GetParameter API to retrieve secrets in your Lambda function.
More information can be found here: <https://docs.aws.amazon.com/systems-manager/latest/APIReference/API_GetParameter.html>
? Do you want to edit the local lambda function now? Yes
Edit the file in your editor: /Users/tomoima525/workspace/aws/amplify/magic-test/amplify/backend/function/magicAuthentication/src/index.js
? Press enter to continue
Successfully added resource magicAuthentication locally.

MAGIC_PUB_KEY は、Magic Console でプロジェクト作成時に取得できるキーです。
では、コードを書いてみましょう。まず、didの検証で使う @magic-sdk/admin をインストールします。以下のコマンドを amplify/backend/function/magicAuthentication/src/ で実行します。

yarn add @magic-sdk/admin

Lambda 関数は以下のようになります。

const AWS = require("aws-sdk");
const { Magic } = require("@magic-sdk/admin");
const cognitoidentity = new AWS.CognitoIdentity({ apiVersion: "2014-06-30" });

const getSecret = async () => {
  return new AWS.SSM()
    .getParameters({
      Names: ["MAGIC_PUB_KEY"].map((secretName) => process.env[secretName]),
      WithDecryption: true,
    })
    .promise();
};

exports.handler = async (event) => {
  const { Parameters } = await getSecret();
  const magic = new Magic(Parameters[0].Value);

  const { didToken, issuer } = JSON.parse(event.body);
  try {
    // Validate didToken sent from the client
    magic.token.validate(didToken);

    const param = {
      IdentityPoolId: process.env.IDENTITY_POOL_ID,
      Logins: {
        // The identifier name you set at Step 2
        [`com.magic.link`]: issuer,
      },
      TokenDuration: 3600, // expiration time of connected id token
    };

    // Retrieve OpenID Connect Token
    const result = await cognitoidentity
      .getOpenIdTokenForDeveloperIdentity(param)
      .promise();

    const response = {
      statusCode: 200,
      headers: {
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Headers": "*",
        "Access-Control-Allow-Methods":
          "DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT",
      },
      body: JSON.stringify(result),
    };
    return response;
  } catch (error) {
    const response = {
      statusCode: 500,
      body: JSON.stringify(error.message),
      headers: {
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Headers": "*",
        "Access-Control-Allow-Methods":
          "DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT",
      },
    };
    return response;
  }
};

この Lambda 関数を動作させるためには、いくつかの工夫が必要です。まず、Lambda 関数から CognitoIdentity を利用するためには、cloudforamtion ファイルでアクセス権を付与する必要があります。cloud-formation-template.yml の lambdaexecutionpolicy の PolicyDocument セクションに下記を追加します。

"PolicyDocument": {
  "Version": "2012-10-17",
  "Statement": [
    {
      ...
    },
    // Add this
    {
      "Effect": "Allow",
      "Action": [
        "cognito-identity:GetOpenIdTokenForDeveloperIdentity"
      ],
      "Resource": {
        "Fn::Sub": [
          "arn:aws:cognito-identity:${region}:${account}:identitypool/${region}:*",
          {
            "region": {
              "Ref": "AWS::Region"
            },
            "account": {
              "Ref": "AWS::AccountId"
            }
          }
        ]
      }
    }
  ]
}

次に、環境変数として IDENTITY_POOL_ID を渡す必要があります。少し難しいので、丁寧に追ってきます。

team-provider-info.yml を開き、Cognito Web console (Idenity pool)にある identityPoolId を追加します。

    "categories": {
      "function": {
        "magicAuth": {
          "identityPoolId": "us-west-2:2477d5cd-cxxxxx", // <- Add this
          "secretsPathAmplifyAppId": "xxdfsd",
          "deploymentBucketName": "amplify-magictest-dev-233202-deployment",
          "s3Key": "amplify-builds/magicAuth-7738665a44657a304142-build.zip"
        },

そして cloud-formation-template.yml に以下も追加します。

{
  "AWSTemplateFormatVersion": "2010-09-09",
  "Parameters": {
    ...
    // Add this
    "identityPoolId": {
      "Type": "String"
    }
  },
  "Resources": {
    "LambdaFunction": {
      ...
      "Properties": {
        "Environment": {
          "Variables": {
            ...
            // Add this
            "IDENTITY_POOL_ID": {
              "Ref": "identityPoolId"
            }
          }
        },
      }
    },
    ...
  }
}

最後に amplify push --yクラウド上にアップデートを展開します。

Step4: API ゲートウェイを追加する

この Lambda 関数にアクセスするために、API Gateway を追加する必要があります。

$ amplify add api
? Select from one of the below mentioned services: REST
✔ Provide a friendly name for your resource to be used as a label for this category in the project: · magicRestApi
✔ Provide a path (e.g., /book/{isbn}): · /auth
✔ Choose a Lambda source · Use a Lambda function already added in the current Amplify project
Only one option for [Choose the Lambda function to invoke by this path]. Selecting [magicAuthFunction].
✔ Restrict API access? (Y/n) · no
✔ Do you want to add another path? (y/N) · no
✅ Successfully added resource magicRestApi locally

ユーザーは認証前にこの API にアクセスする必要があるため、アクセスを制限しないことを注意してください。

フロントエンドの実装

それでは、Frontend の実装に移りましょう。今回フロントエンドはReactを利用しています。

  • Step1: ユーザーセッションの確認
  • Step2:サインアップの実装
  • Step3:コールバックの受け取りと認証処理
  • Step4:トークンのリフレッシュロジック

Step1:ユーザーセッションを確認する

まず、ユーザーセッションを確認します。アプリのエントリポイントに、以下を追加します。

  useEffect(() => {
    setUser({ loading: true });
    Auth.currentUserCredentials()
      .catch((e) => {
        console.log("=== currentcredentials", { e });
      });
    Auth.currentAuthenticatedUser()
      .then((user) => {
        magic.user
          .isLoggedIn()
          .then((isLoggedIn) => {
            return isLoggedIn
              ? magic.user
                  .getMetadata()
                  .then((userData) =>
                    setUser({ ...userData, identityId: user.id })
                  )
              : setUser({ user: null });
          })
          .catch((e) => {
            console.log("currentUser", { e });
          });
      })
      .catch((e) => {
        setUser({ user: null });
      });
  }, []);

Auth.currentUserCredentials()トークン更新のトリガーとなります(Step4 で実装します)。Auth.currentAuthenticatedUser() は Cognito に格納されているユーザ情報をチェックします。そして、Magic がこのユーザーを認証しているかどうかも合わせてここでチェックします。

Step2: サインアップフローの追加

ユーザーがサインアップしていない場合、ログイン画面にリダイレクトします。ユーザーがメールアドレスを投稿したら、Magic でメールを送信します。以下のコードがメールを発行するためのサインアップのコードです。

  async function handleLoginWithEmail(email) {
    try {
      // Prevent login state inconsistency between Magic and the client side
      await magic.user.logout();
      // Trigger Magic link to be sent to user
      await magic.auth.loginWithMagicLink({
        email,
        redirectURI: new URL("/callback", window.location.origin).href, // optional redirect back to your app after magic link is clicked
      });
    } catch (error) {
      console.log(error);
    }
  }

これを実行すると以下のような画面がでるはずです。

f:id:tomoima525:20211220172635p:plain

ステップ 3:コールバックの受信と認証

コールバック画面は、すべての認証処理が行われる場所です。ここで、expire_atに 1 時間先を設定してます。トークンの有効期限は 1 時間なので、1 時間より大きな数字でもかまいません。

  const authenticateWithServer = async (didToken) => {
    let userMetadata = await magic.user.getMetadata();
    // Get Token and IdentityId from Cognito
    const res = await API.post(
      awsconfig.aws_cloud_logic_custom[0].name,
      "/auth",
      {
        body: {
          didToken,
          issuer: userMetadata.issuer,
        },
      }
    );

    // Federated Sign in using OpenId Token
    const credentials = await Auth.federatedSignIn(
      "developer",
      {
        identity_id: res.IdentityId,
        token: res.Token,
        expires_at: 3600 * 1000 + new Date().getTime(),
      },
      user
    );
    if (credentials) {
      // Set the UserContext to the now logged in user
      let userMetadata = await magic.user.getMetadata();
      await setUser({ ...userMetadata, identityId: credentials.identityId });
      history.push("/profile");
    }
  };

これで、このユーザーは認証され、プライベート APIAWS リソースにアクセスできるようになりました。Cognito Dashboard でこのユーザが認証されていることがわかります。

f:id:tomoima525:20211220172747p:plain

Step4: トークンの更新を追加する

最後に、トークンを更新するロジックを追加します。前述したように、Amplify はカスタム認証を使用する際、自動的にトークンをリフレッシュすることはありません。幸いなことに、手動で更新するように Amplify を設定することができます。

アプリケーションのエントリポイントである index.js に、以下を追加します。

async function refreshToken() {
  const didToken = await magic.user.getIdToken();
  const userMetadata = await magic.user.getMetadata();
  const body = JSON.stringify({
    didToken,
    issuer: userMetadata.issuer,
  });
  const res = await fetch(
    `${awsconfig.aws_cloud_logic_custom[0].endpoint}/auth`,
    {
      method: "POST",
      body,
    }
  );
  const json = await res.json();
  return {
    identity_id: json.IdentityId,
    token: json.Token,
    expires_at: 3600 * 1000 + new Date().getTime(),
  };
}

Auth.configure({
  refreshHandlers: {
    developer: refreshToken,
  },
});

リフレッシュされたトークンと ID を取得するためのシンプルなリクエストが必要なため、fetch を使用します。この関数は、前回のトークン更新から 1 時間経過するたびにトークンをリフレッシュします。この関数自体は Auth.currentUserCredentials()を呼ぶときに内部的にチェックされコールされます。

これで完成です!

注意すべきこと

パスワードレス認証の導入・移行を検討する場合、いくつかの点に注意する必要があります。

  • Cognito User Pool に依存しなくなる。バックエンドはシンプルになりますが、Cognito User Pool が提供するいくつかの利点も失われます。例えば、グループアクセス制御は使用できません。
  • Cognito Federated Identities のカスタムプロバイダーは、一度設定すると更新できません。識別子名は慎重に定義してください。複数の環境がある場合、開発環境は com.magic.link.dev 、本番環境は com.magic.link.production というように名前を変更する必要があります。このようにすることで、Identity pool を共有することがなくなります。

パスワードレス認証を越えて ~ Web2 と Web3 をつなぐ

今回は、Email を使ったパスワードレス認証の実装方法を紹介しました。この手法は実はさらに応用が効きます。 Magic は様々な暗号化ウォレットの接続をサポートしています。ウォレットが接続されると、一意のアドレスが取得できます。つまり、Web3 アプリケーションにアクセス制御を適用する場合は、基本的に同じアプローチが可能です。今回はそれを紹介するつもりはありませんが、ぜひサンプルコードを参考に試してみてください!