WEB OTP APIでワンタイムパスワードのUXをさっくり改善してみる

# はじめに

ワンタイムパスワードの入力補助API、 **WEB OTP API**をご存じでしょうか？  
プロジェクトで触ってみる機会があり、こんなことができるのか！と感動したので、その感動を共有してみたいと思います。
# どんなことができるの？

![無題-2022-10-15-1823.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2883156/db254133-79e3-aa89-6a62-187ce317a455.png)


スマートフォンでSMSメッセージを受信後に、「送信」ボタンをクリックすると、PCのブラウザにワンタイムパスワードが送信されます。

![Oct-16-2022 06-02-32.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2883156/a10c0643-aef5-f96a-100e-6ab48fba0923.gif)

# そもそもOTPとは？

OTPは「**one-time-password**」の略で、訳すと一度きりのパスワードです。
2段階認証などで、ユーザーのログインの際に追加でSMSなどに送られてくるコードのことを指します。

# OTPが抱える課題

OTPはSMSであれば電話番号の所有確認に加えて、ユーザーの持っている電話番号であるかを確認でき、セキュリティ向上が期待できます。

一方でユーザー操作が増えるため、特に**デバイスの横断**（e.g. PCでログインしたいが、スマートフォンにメッセージが来る時など）が発生した場合は、**ユーザービリティを低下させてしまう可能性**を孕んでいます。

スマホで 2段階認証の画面を開いている場合などは1端末の操作ですみますが、PCでログインをしたい時などは操作が面倒になってしまいます。
PCでまずはID/Passを入力して送信、2段階認証画面が表示されるので、スマートフォンに送信されて来たSMSメッセージを見て、PCに向かってコピペはできないから手入力して、、、、と、実際にやったことのある人にはわかる、端末を行ったり来たりしないとならないという非常に面倒な操作が求められます。

そんなときにユーザーの利便性を向上するのが今回紹介する**WEB OTP API**になります。

# WEB OTP APIとは

WEB OTP APIはワンタイムパスワードの入力補助をしてくれるAPIになります。
SMSが配信された時にスマートフォンなどで受信したコードがキーパッド上部にサジェストされるのはよくみたことがあるかと思いますが、サジェストのその先、たとえば自動入力などをさせることができるようになるのがこのAPIです。

:::note warn
同一のアカウントで Chrome にログインする必要があります
:::

# 使用可能なブラウザ

WEB OTP APIはGoogleが提案しているAPIになります。  
そのため、使用可能なブラウザは主にChormiumベースのブラウザが対象になっています。

- Chrome
- Edge
- Opera
- Chrome for android
- Samsung Internet
- Opera Mobile

Chromeがサポートしているので、ユーザーの多さを考えると、サポートしておいて損はなさそうです。

:::note info
Safariの場合はinputのフォームに`auto-complete="one-time-code"`の属性を設定するだけで同様の処理を実現が可能です。
::: 


# 実際に実装してみる

さて、ここからは実装に入っていきたいと思います。  
WEB OTP APIを使うために必要なことは下記２点です。

1. W3Cが定めた形式でSMSメッセージを送信する
2. フロントエンドでWEB OTP APIを使い、コードを受け取る

## 1. SMSメッセージを送信する

### SMS の配信形式

SMSの配信形式ですが、こちらをまず、W3Cが定めた **Origin-bound one-time codes delivered via SMS**の形式に合わせる必要があります。 

上の英語は要約すると、「**originを証明したSMSで配信されるOTPの形式**」、ということです。  
この仕様はSMS OTPとwebサイトを紐付ける仕様で、W3Cのコミュニティ内部で提案されている仕様になります。 
 
元々はSPなどのデバイスで別のサイトから送信されたOTPが自動入力がされてしまうことがあり、フィッシングサイトなどで自動入力を防ぐために提案された仕様です。  

この仕様をもとに、ドメインが一致するWebサイトへの自動入力を促すべくGoogleが提案したJavascript APIが今回使うWEB OTP APIになります。
そのため、まずはSMSの形式をフォーマット通りにしましょう。

```
Your OTP is 123456

@test.com #123456
```

上記が提唱されている形式となります。
こちらで大事なところは、一番最後の行だけです。この一文を正しく記載する必要があります。
詳しい形式は下記になります。

1. `@`移行に OTP を入力するサイトのドメインを記載する
2. 1で記載したドメインの後に`#`をつけて、ワンタイムパスワードを記載する

こちらの形式を守ることで、今回のWEB OTP APIを使えるほか、異なるドメインであれば、OTPのコードの入力補助をさせないように防ぐことができます。

### SMSメッセージを送信できるようにする

今回は[Supabase](https://supabase.com/)のedge functionsを使ってSMSメッセージを送信していきます。
edge functionsは ユーザーの近くのエッジ環境で実行することができるのでレイテンシーを低く抑えることができます。

:::note info
**Requirements**
下記アカウントが必要になります。
- [Twilio](https://console.twilio.com/)
- [Supabase](https://supabase.com/)
:::

#### 1. supabaseプロジェクトを作成する
![supabase1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2883156/247e8d5d-2594-595a-8a90-9aeedd4d9bc5.png)
[Supabase](https://app.supabase.com/)「New Project」からプロジェクトを作成します。
* Region ⇒ Tokyo Regionを選ぶ

#### 2. Supabase CLIをインストール

```shell
# CLIをインストールする
$ brew install supabase/tap/supabase
```

#### 3. Supabase Edge functionsの環境作成

```shell
# Supabaseアカウント（app.supabase.com）を使ってログインする
supabase login
```

```shell
# Supabaseを初期化
supabase init
```
ローカルでプロジェクトを開発するためのすべての設定を保持する`supabase`フォルダーが作成されます。
```shell
.
└── supabase/
    ├── .gitignore
    └── config.toml
```

```shell
# プロジェクトとリモートのプロジェクトを関連付ける
supabase link --project-ref {Reference ID} 

# 関数作成用のボイラープレート作成
supabase functions new sms-message
```
これで関数作成用の雛形(`/sms-message/index.ts`)ができました。
最終的に下記のようなディレクト構成になります。

```shell-session
.
├── supabase/
│   ├── .temp
│   ├── functions/(*)
│   │   ├── _shared
│   │   └── sms-message/
│   │       ├── helper/
│   │       │   ├── twillioSms.ts
│   │       │   └── messageTemplate.ts
│   │       ├── types/
│   │       │   └── sms.ts
│   │       ├── deps.ts
│   │       └── index.ts(*)
│   ├── .env
│   ├── .gitignore
│   └── config.toml
└── deno.jsonc
```

#### 4. 環境変数を登録する
![スクリーンショット 2022-10-16 3.15.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2883156/71f37a50-eb73-30f5-1450-f8f60643bc2a.png)

:::note warn
トライアルアカウントでは、管理画面で`verify`された電話番号へのみSMSが送信が可能です。
:::

Twillioの管理画面Account Infoから下記3点を環境変数に登録する

```shell: .env
TWILIO_ACCOUNT_SID={Account SID}
TWILIO_AUTH_TOKEN={Auth Token}
TWILIO_PHONE_NUMBER={My Twilio phone number}
```


```shell
# .envファイルからSupabaseへ環境変数を登録。
supabase secrets set --env-file ./supabase/.env

# 登録した環境変数一覧表示
supabase secrets list

# 環境変数削除
supabase secrets unset
```

#### 5. 関数を作成する

`TwilioSms`ヘルパークラスを作成する

```TypeScript: supabase/functions/send-message/helper/twilio-sms.ts
import {base64} from '../deps.ts';
import {SMSRequest} from '../types/sms.ts';

/**
 * Twilio SMS Client Class
 */
export class TwilioSms {
  private readonly authorizationHeader: string;

  /**
   * Create Authorization Header
   * @param accountSID
   * @param authToken
   */
  constructor(private accountSID: string, authToken: string) {
    this.authorizationHeader = `Basic ${
      base64.fromUint8Array(
        new TextEncoder().encode(`${accountSID}:${authToken}`),
      )
    }`;
  }

  /**
   * SMS送信
   * @param payload SMSRequest
   */
  async sendSms(payload: SMSRequest): Promise<any> {
    const res = await fetch(
      `https://api.twilio.com/2010-04-01/Accounts/${this.accountSID}/Messages.json`,
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
          Authorization: this.authorizationHeader,
        },
        body: new URLSearchParams(payload).toString(),
      },
    );

    return await res.json();
  }
}

```
ここでは、登録した環境変数を取得して、TwilioSmsのインスタンス化して、sendSmsでメッセージを送信します。

```TypeScript: supabase/functions/send-message/index.ts
import {serve} from './deps.ts';
import {corsHeaders} from '../_shared/mod.ts';
import {TwilioSms} from './helper/twilio-sms.ts';
import {MessageTemplates} from "./helper/messageTemplate.ts";

export const accountSid = Deno.env.get('TWILIO_ACCOUNT_SID') || '';
export const authToken = Deno.env.get('TWILIO_AUTH_TOKEN') || '';
export const fromMobile = Deno.env.get('TWILIO_PHONE_NUMBER') || '';

// SMSメッセージを送る関数
serve(async (req) => {
  if (req.method === 'OPTIONS') {
    return new Response('ok', {headers: corsHeaders});
  }
  
  const {toMobile} = await req.json() || '';
  const twilioClient = new TwilioSms(accountSid, authToken);

  const {status = ''} = await twilioClient.sendSms({
    Body: MessageTemplates.otpMessage('123456'),
    From: fromMobile,
    To: toMobile,
  });

  const data = {
    isSuccess: false,
  };

  if (status === 'queued') {
    data.isSuccess = true;
  }

  return new Response(JSON.stringify(data), {
    headers: {
      ...corsHeaders,
      'Content-Type': 'application/json',
    },
  });
});
```

#### 6. 作成した関数をDeployする
```
# Deployする
supabase functions deploy send-message
```
これでSMSを送信する処理をデプロイすることができました。

```TypeScript
// クライアント側では、下記で関数を呼びだすことができます。
const { data, error } = await supabase.functions.invoke('send-message', {
  body: JSON.stringify({ toMobile: phone })
});
```
細かい部分は省略させていただきましたが、詳細は下記レポジトリをご確認ください。
SMS送信関数のレポジトリ：

https://github.com/itwillrain/otp-handson-functions

## 2. フロントエンドでWEB OTP APIを使い、コードを受け取る

さて、いよいよ本題のフロントエンドの実装に入りましょう。
今回使用した主要な技術は下記になります。

<details>
<summary>主要な使用技術</summary>

* Nextjs
* react hook form
</details>

今回はWEB OTP APIの実際に処理をおこなっている箇所を抜粋して説明をしていきます。
（より詳しくみたい方は、サンプルリポジトリの`src/pages/otp/index.tsx`をご確認ください。）

```tsx
/** OTP Credential API処理 */
useEffect(() => {
  // OTP Credential APIが使用可能かどうか
  if (!("OTPCredential" in window)) {
    return;
  }

  // one-time-codeのを入力するinputエリアを取得
  const input = document.querySelector('input[autocomplete="one-time-code"]');

  if (input == null) {
    return;
  }

  // フォームを取得
  const form = input.closest("form");
  const ac = new AbortController();
  const abort = (): void => ac.abort();

  if (form != null) {
    // ユーザー操作で送信したらabort
    form.addEventListener("submit", abort);
  }

  // OTP処理
  navigator.credentials
    .get({
      // @ts-expect-error
      otp: { transport: ["sms"] },
      signal: ac.signal,
    })
    .then(async (otp) => {
      // @ts-expect-error
      setValue("otp", otp?.code ?? "");
      await trigger("otp");

      if (isValid) {
        // 送信処理
        submitOTP({ otp: getValues("otp") });
      }
    })
    .catch((error) => {
      console.log(error);
    });

  // listenerをremoveする
  return () => {
    if (form != null) {
      form?.removeEventListener("submit", abort);
    }
  };
}, [getValues, isValid, setValue, submitOTP, trigger]);
```

なんだか色々とやっていますが、実は本当の肝の部分は下に記載するたった10数行の部分だけになります。

```tsx
// ①
navigator.credentials
  .get({
    otp: { transport: ["sms"] },
    signal: ac.signal,
  })
  .then(async (otp) => {
    // ②
    setValue("otp", otp?.code ?? "");

    // 省略
  })
  .catch((error) => {
    console.log(error);
  });
```

この部分です！  
していることはとても単純です。

1. `navigator.credentials.get` で `otp: { transport: ["sms"] }` を指定、OTPがSMSから送信されるのを待機する
2. `then`の節の内部で、`code`を受け取り、行いたい処理をする。

WEB OTP APIのPromiseが解決するまで待ち、Promiseが解決したらオブジェクトからコードを受け取る、至ってシンプルな実装です。
then節で受け取ったコードを使いフォームの自動入力などを行わせることができます。

より詳しい実装は公開しているリポジトリをご覧ください。

https://github.com/KosukeTakahashi0410/one-time-password-sample

# 終わりに

WEB OTP APIの良さは実装者それほど大きなコストをかけずとも対応することができるが、ユーザーの利便性を上げる所にあると思います。

ユーザーから見れば、PCのフォームがSPを少し操作するだけで入力され、ほとんど煩わしさもなく認証を済ませられます。
端末を横断するので、面倒そうな実装なのかなと初見だと感じそうですが、実際はたった20行にも満たないというのが実装者としては魅力的かなと思っています。

また、UXを高めるだけでなく、SMSをW3Cの指定する形式にするだけでもセキュリティの向上が見込まれるので、もし開発要件に2段階認証があった、そんな時には提案するのも良いかなと思いました。

# 参考文献

[Origin-bound one-time codes delivered via SMS](https://wicg.github.io/sms-one-time-codes/)