Auth0のログインID（メールアドレス）を変更する方法を考えてみた

## はじめに
Auth0のUniversal Loginにはパスワードリセットの機能はありますが、ログインID（メールアドレス）を変更する機能がなさそうなので、Auth0を利用するアプリからメールアドレスを変更する方法を考えてみました。

## メールアドレスの変更手順
単純にユーザーに新しいメールアドレスを入力してもらって、Management APIでメールアドレスを更新！
とやってしまうと、誤ったメールアドレスが入力された場合その内容で更新されてしまうのでログインが出来なくなってしまう恐れがあります。
新しいメールアドレスの妥当性を確認しつつ更新するには以下のような手順を踏む必要があると考えました。

1. ユーザーがアプリにログインする
2. ユーザーに新メールアドレスを入力してもらう
3. 新メールアドレスにメール送信
4. ユーザーは新メールアドレスに届いたメールを確認
5. 新メールアドレスでユーザー情報（メールアドレス）を更新

この中の4のメールの受信確認ですが、よくある手法として

* slackのようにマジックリンクをメールに記載し、ユーザーにリンクを踏んでもらう
* メールにワンタイムパスワード（OTP）を記載しておき、アプリの画面からユーザーにOTPを入力してもらう。

の2つがあると思います。
しかし、いざ実装しようとするとマジックリンクやOTPの生成方法や有効期限を考える必要があるので、自前で作るのは結構大変そうです。

できるだけ自前での実装はやりたくなかったので、Auth0のパスワードレス認証を利用してこの手順を実現してみました。

## シーケンス図
大まかな流れをシーケンス図にするとこうなります。

![change_email_address.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/490013/23a02c33-872a-934c-ee3a-1aac6f001518.png)


* ユーザーはログイン済みの状態から新しいメールアドレスを使ってパスワードレス認証（OTP）を行う。
    * マジックリンクでのパスワードレス認証はいろいろな都合でうまくいかなかったのでOTPを利用
* パスワードレス認証の流れで新しいメールアドレスの有効性を確認。
* Auth0を利用するクライアントアプリはパスワードレス認証の結果としてトークンを取得。
    * このとき、クライアントアプリは変更前のメールアドレスでログインしたときのトークンと、パスワードレス認証で取得したトークンの2つを保持する。
* パスワードレス認証で取得したIDトークンを検証し、クレームから新しいメールアドレスを取得。
    * アクセストークンを使って `GET /userinfo`からメールアドレスを取得してもOK
* Management APIを叩くためのアクセストークンを取得
    * `update:users`と`delete:users`のscopeが必要
* パスワードレス認証で作成されたユーザーを削除し、現在ログインしているユーザーのメールアドレスを更新する。
* メールアドレスの更新が成功したら、新旧両方のメールアドレスに通知を飛ばす。
    * 新しいメールアドレスで既にユーザー登録されていて更新がエラーとなった場合はその旨を通知。


こんな感じでやってみました。
一応、新しいメールアドレスが既に登録済みであることを示すメッセージは、メールアドレスの有効性確認後にしか表示しないようにして、登録済みかどうかはそのメールアドレスの所有者にしかわからないようにしています。

これでスクリーニング対策になってる…かな？

## Auth0の設定
### クライアントアプリの設定
DashBoardのApplicationsからクライアントアプリをRegular Web Applicationとして登録。
適当にCalback URLsなんかを設定して、普通にUniversal Loginが使えるようにします。
あと、忘れずにAdvanced SettingsのGrant Typeにある`Passwordless OTP`にチェックを入れておきます。

### M2Mアプリの設定
DashBoardのAPIsを開き、`Auth0 Management API`を選択します。
Machine to Machine Applicationsのメニューを開き、先に設定したクライアントアプリのトグルボタンをONにし、APIのpermissions設定で`update:users`と`delete:users`のscopeにチェックを入れておきます。

これでクライアントアプリからManagement APIを叩けるようになります。

### パスワードレス認証設定
`DashBoard > Connections > Passwordless`を押して、Emailのパスワードレス認証を有効化し、SettingsのAuthentication Parametersを以下のように編集します。

```javascript
{
  "scope": "openid email"
}
```

あとはApplicationsからクライアントアプリのパスワードレス認証を有効化すればOKです。

## クライアントアプリの実装

クライアントアプリはNode.js（Express.js）で実装してみました。
以降のコードは主要部分のみの抜粋です。

### パスワードレス認証の開始
シーケンス図の2と3に該当します。
ユーザーに新しいメールアドレスを入力してもらい、パスワードレス認証を開始します。

```javascript
const axios = require('axios');

router.post('/change-email', async (req, res) => {
  // パスワードレス認証の開始
  await axios.post(
    'https://{Auth0のドメイン}/passwordless/start',
    {
      client_id: '{クライアントID}',
      client_secret: '{クライアントシークレット}',
      connection: 'email',
      email: '{新しいメールアドレス}',
      send: 'code',
    },
  );

  res.redirect('/otp'); // OTP入力画面にリダイレクト
});
```

### OTPとトークンを交換
シーケンス図の6に該当します。
OTPを入力する画面ではCSRF対策をしたほうが良いと思います。

```javascript
const axios = require('axios');
const querystring = require('querystring');

const response = await axios.post(
  'https://{Auth0のドメイン}/oauth/token',
  querystring.stringify({
    grant_type: 'http://auth0.com/oauth/grant-type/passwordless/otp',
    client_id: '{クライアントID}',
    client_secret: '{クライアントシークレット}',
    username: '{新しいメールアドレス}',
    realm: 'email',
    otp: '{OTP}',
  }),
);
```
 
### IDトークンの検証

シーケンス図の9に該当します。
IDトークンの検証と同時にクレームを取得しています。

```javascript
const jwksClient = require('jwks-rsa');
const jwt = require('jsonwebtoken');

const userInfo = async (idToken) => new Promise((resolve, reject) => {
  const client = jwksClient({
    jwksUri: 'https://{Auth0のドメイン}/.well-known/jwks.json',
    cache: true,
    rateLimit: true,
    jwksRequestsPerMinute: 5,
  });
  const getKey = (header, callback) => {
    client.getSigningKey(header.kid, (err, key) => {
      const signingKey = key.publicKey || key.rsaPublicKey;
      callback(null, signingKey);
    });
  };
  const options = {
    algorithms: ['RS256'],
    audience: '{クライアントID}',
    issuer: 'https://{Auth0のドメイン}/',
  };

  jwt.verify(idToken, getKey, options, (error, decoded) => {
    if (error) {
      reject(error);
    } else {
      resolve(decoded);
    }
  });
});

```

### Management API用のアクセストークンを取得

シーケンス図の10と11に該当します。
Management APIのアクセストークンはキャッシュしておいて、有効期限が切れたら再取得するようにしておくと良いです。

```javascript
const axios = require('axios');

const params = new URLSearchParams();
params.append('audience', 'https://{Auth0のドメイン}/api/v2/');
params.append('scope', 'update:users delete:users');
params.append('grant_type', 'client_credentials');
params.append('client_id', '{クライアントID}');
params.append('client_secret', '{クライアントシークレット}');

const responseToken = await axios.post(
  'https://{Auth0のドメイン}/oauth/token', params
);

```

### パスワードレス認証で作成されたユーザーを削除

シーケンス図の12に該当します。
先にパスワードレス認証で取得したIDトークンのsubクレームを指定してユーザーを削除

```javascript
const axios = require('axios');

await axios.delete('https://{Auth0のドメイン}/api/v2/users/{IDトークンのsubクレーム}', {
  headers: { Authorization: 'Bearer {Management APIのアクセストークン}' },
  data: {},
});
```

### メールアドレスの更新

シーケンス図の13に該当します。
更新対象のユーザーは元々ログインしているほうのユーザーなので間違えないようにしましょう。

```javascript
await axios.patch(
  'https://{Auth0のドメイン}/api/v2/users/{現在ログインしているユーザーのsub}',
  querystring.stringify(
    {
      email: '{IDトークンのemailクレーム}',
      email_verified: true,
    },
  ),
  { headers: { Authorization: 'Bearer {Management APIのアクセストークン}' } },
)
  .catch(
    (error) => // エラーハンドリング
  );
```

変更後のメールアドレスが既に存在している場合、このAPIリクエストはエラーとなって以下のレスポンスが帰ってきます。

```javascript
{ statusCode: 400,
  error: 'Bad Request',
  message: 'The specified new email already exists',
  errorCode: 'auth0_idp_error' }
```
このエラーが帰ってきたらユーザーに「このメールアドレスは既に登録されています。」などの通知を出すと良いと思います。


## このメールアドレス変更手順の注意点
ここまでAuth0のパスワードレス認証の仕組みを利用したメールアドレス変更手順を紹介してきましたが、いくつか注意点があります。

### パスワードレス認証の使い方
そもそもパスワードレス認証はこんな使われた方を想定した機能ではないはずなので、何か不都合があるかもしれませんし、今後パスワードレス認証の仕様が変わったときに想定外の挙動をする可能性もあります。
もしもこの内容を試されるときは、そういった点を承知の上で実施してください。

### RulesでLink Accounts with Same Email Addressを設定している場合

Rulesの`Link Accounts with Same Email Address`を設定して同一メールアドレスのアカウントリンクを実施していると
新しく入力されたメールアドレスが既に登録済みの場合に既存のアカウントと新しいメールアドレスで作成されたユーザーが統合され、既存アカウントごとユーザーが削除されてしまいます。

Rulesで`Link Accounts with Same Email Address`を設定している場合は、パスワードレス認証で作成されたユーザーはアカウントリンクの対象外にする必要があります。

```javascript:Rulesの冒頭部分
function (user, context, callback) {
  const request = require('request');

  // Emailによるパスワードレス認証の場合はアカウントリンクの対象外となるように条件追加
  if (!user.email || !user.email_verified || context.connection === "email") {
    return callback(null, user, context);
  }
```

### クライアントアプリのタイプ
このメールアドレス変更フローはクライアントアプリがConfidential Clientでないと成功しません。
SPAやNative AppのようなPublic Clientだと、Management APIのscope（`update:users`と`delete:users`）が取れないので失敗します。


## おわりに

Universal Loginの標準機能でログインIDを変更する機能が追加されると嬉しいんですが
現状そんな機能はないので、無理やりAuth0の機能を使って実装してみました。
もっと他にスマートなやり方があれば誰か教えて欲しいです。