Supabase × Expo で OTPとMFAを使用した認証実装

# はじめに
今回はExpoとSupabaseで認証機能を実装していきます。

**OTP**（ワンタイムパスワード）を使った検証や、**MFA**（2要素認証）の実装方法についてご説明いたします。

#### ✅ 今回実装すること
- サインアップ （メールアドレス）
    -  サインアップ確認（OTP検証）
    -  MFA登録（TOTP）
-  サインイン
    -  MFA認証（TOTP）
-  パスワード再設定（OTP検証）

# Expoとは
React Nativeアプリ開発を簡単にするフレームワークであり、AndroidやiOSのネイティブ開発をすることなく、**クロスプラットフォーム対応**のモバイルアプリを構築できます。

ネイティブコードを細かくカスタマイズしたい場合は、Expo を部分的に解除する必要性がある場合もあります。

https://expo.dev/blog/why-expo-is-a-great-fit-for-new-and-existing-react-native-apps#conclusion

# Supabaseとは
オープンソースのFirebase代替として知られるBaaSです。

PostgreSQLをベースにしたデータベースや認証、ストレージ、リアルタイム機能を提供し、サーバーレスでバックエンドを構築できるのが特徴です。

SupabaseはExpo（React Native）とも相性が良く公式の@supabase/supabase-js SDKを使えば、認証・データベース・ストレージを簡単に組み込めます。

https://supabase.com/

# Supabase Authについて
Supabase Authは認証（Authentication）と 承認（Authorization） を提供するサービスでJWT（JSON Web Token）を使った認証を行います。

また、PostgreSQL の行レベルセキュリティ（RLS）と統合されており、高度なアクセス制御が可能です。

### ✅ 主な機能
- JWTの発行
- PostgREST を活用した行レベルセキュリティ（RLS）
- ユーザー管理機能（ユーザーの作成、更新、削除など）
- 多様なサインイン方法
    - メール & パスワード
    - マジックリンク（パスワード不要の認証）
    - 電話番号（SMS 認証）
    - OAuth（Google、Apple、Facebook、Discord など）
    - Enterprise Single Sign-On
    - MFA（Multi-Factor Authentication）

# OTPとは

OTP（ワンタイムパスワード）とは、一度限り使用できる使い捨てのパスワードで主に認証や検証に利用されます。

**OTPの特徴**
🔹 **一度きりの利用**：使い回しができず、再利用が防げる。
🔹 **時間制限付き（TOTP）**：一定時間ごとに新しいコードが生成される。
🔹 **イベントベース（HOTP）**：特定のアクションごとに新しいコードが発行される。
🔹 **フィッシング耐性**：攻撃者が盗んでも次回には無効になる。

**OTPの用途**
🔹 **認証**：ログインや二要素認証（2FA）
🔹 **検証**：本人確認、登録時の確認、パスワード変更、取引承認
🔹 **認可**：APIリクエストや特定操作の一時的許可

今回OTPを使用するのはサインアップ時のメールアドレスの確認やパスワード変更時の本人確認のタイミングです。
Supabase Authの機能を用いて使用することができます。

# MFAとは

MFA（Multi-Factor Authentication）は、2要素認証（2FA）とも呼ばれ、アプリのセキュリティを強化する仕組みです。

ユーザーのアカウント乗っ取りを防ぐために、以下の2つの要素を要求します。

🔹 **知っている情報**（パスワード、ソーシャルログインなど）
🔹 **持っているもの**（認証アプリや携帯電話）


Supabase Authは、以下2つの方法でMFAを提供します。

🔹 **認証アプリ(TOTP)** : 時間ベースのワンタイムパスワード（Google Authenticatorなど）
🔹 **SMS** : Supabase Auth が生成したコードをSMSで送信
　※SMSはプロプランの登録が必要

そしてMFAには2つのフローがあります。

🔹 **登録フロー** : ユーザーがMFAを設定・管理できるようにする
🔹 **認証フロー** : 従来のログイン後に追加の要素でサインイン

# プロジェクトのセットアップ
supabaseでプロジェクトを作成したらSettingsからProject URLと Project API Keysの anon publicの値を控えます。
![スクリーンショット 2025-03-02 14.22.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3944203/3c9066b5-c6a9-41ec-9e89-b0dc625b59b1.png)

Expoでプロジェクトを作成したら@supabase/supabase-js次のコマンドを使用して必要な依存関係をインストールします。
```terminal:terminal
npx expo install @supabase/supabase-js @react-native-async-storage/async-storage react-native-url-polyfill
```

Supabase クライアントを初期化するためのファイルを作成します。
supabaseUrlとsupabaseAnonKeyには先ほどSupabaseのSettingsで控えておいた値をセットします。
```tsx:utils/supabase.ts
import 'react-native-url-polyfill/auto';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createClient } from '@supabase/supabase-js';

const supabaseUrl = YOUR_REACT_NATIVE_SUPABASE_URL;
const supabaseAnonKey = YOUR_REACT_NATIVE_SUPABASE_ANON_KEY;

export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
  auth: {
    storage: AsyncStorage,
    autoRefreshToken: true,
    persistSession: true,
    detectSessionInUrl: false,
  },
});
```
これで、アプリケーション全体でSupabaseを活用できるようになりました。
# サインアップ（メールアドレス）
まずは、サインアップの実装をします。
```signUp```メソッドを使用し、```email```と```password```を引数として指定します。

```jsx:サインアップ
export async function signUpWithEmail(email: string, password: string) {
  await supabase.auth.signUp({ email, password });
}
```
# サインアップ確認（OTP）
次に、メールアドレスの本人確認です。
サインアップに使用したメールアドレスを確認するためのメールが送られます。

Supabaseではこの確認方法については変更することが可能です。
デフォルトでは確認用のURLを含んだメールアドレスが送付されますが、今回はOTPをメールアドレスに含む方法で実装します。

こちらの画像のようにSupabaseの管理画面のAtuhenticationから「Confirm signup」のSourceに ```{{.Token}}```が含まれるように変更し保存します。
![スクリーンショット 2025-03-02 15.09.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3944203/000bfaee-5f2d-4319-961d-f9e7fb228284.png)

これによりサインアップ時に、使用したメールアドレス宛に6桁のトークンが送られるようになります。

<メール内容＞
![スクリーンショット 2025-03-02 15.34.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3944203/e9a3548a-f33e-463d-8ae5-68f46d198df7.png)

ユーザーがメールで受け取った6桁のトークンを入力できるフォームを用意し、Supabase Authを用いてOTPの認証機能を実装をします。

```verifyOtp```メソッドを使用し、```email```と```password```を引数として指定します。

```jsx:サインアップ
export async function signUpWithEmail(email: string, token: string) {
  await supabase.auth.verifyOtp({
    email,
    token,
    type: "email",
  });
}
```

この```verifyOtp```メソッドは、さまざまな検証タイプを受け入れます。

電話番号が使用される場合、タイプは```sms```または```phone_change```のいずれかになります。メールアドレスが使用される場合、タイプは```email```、```recovery```、```invite```、```email_change```のいずれかとなります。
※```signup```と```magiclink```は非推奨です

# パスワード再設定（OTP）
続いて、パスワード再設定です。
サインアップに時の確認メールと同様にOTPを用いてパスワード再設定を実施します。

先程と同様に画像のようにSupabaseの管理画面のAtuhenticationから「Reset Password」のSourceに```{{.Token}}```が含まれるように変更し保存します。
![スクリーンショット 2025-03-02 16.51.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3944203/e9dfa8d9-70b4-4124-8e76-0adac4060306.png)


そして```resetPasswordForEmail```メソッドを使用することで、特定のメールアドレスに再設定メールを送信することができます。


```jsx:パスワード再設定メール送信
export async function passwordReset(email: string) {
  await supabase.auth.resetPasswordForEmail(email);
}
```

<メール内容＞
![スクリーンショット 2025-03-02 16.59.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3944203/54bf3deb-a70c-47af-81a1-ae497576ce83.png)

メールで受け取った6桁のトークンと新しいパスワードを入力できるフォームを用意し、トークンと新しいパスワードを元に```verifyOtp```メソッドを叩くとサインイン状態になるのでパスワードの再設定を実施できます。

```jsx:パスワード再設定
export async function updatePassword(email: string, password: string, token: string) {
  const { data } = await supabase.auth.verifyOtp({
    email,
    token,
    type: "recovery",
  });
  const { error } = await passwordUpdate(
    password,
    data.session?.access_token || "",
  );
}
```

# MFAの設定
ここからはMFAの設定です。
認証アプリとSMSの2パターンありますが無料で使用可能な認証アプリ（TOTP）を選択します。

### 分岐処理について
登録の前にまずはユーザーがログインした後、追加の要素を検証する必要があるかどうかを確認する必要があります。

`mfa.getAuthenticatorAssuranceLevel()` メソッドを使用して、ユーザーの現在の認証保証レベル（AAL）と次の認証保証レベル（AAL）を確認できます。

```jsx
const { data } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
```
#### レベルの詳細
| 現在のレベル | 次のレベル | 意味 |
|------------|----------|------|
| aal1       | aal1     | ユーザーにMFAが登録されていません。 |
| aal1       | aal2     | ユーザーはMFA要素を登録していますが、検証していません。 |
| aal2       | aal2     | ユーザーはMFA要素を確認しました。 |
| aal2       | aal1     | ユーザーがMFA要素を無効にしました（古いJWT）。 |


Expo Routerを用いて以下のように分岐させます。

```jsx:
const { data } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
if (data.currentLevel === "aal1") {
  if (data.nextLevel === "aal1") {
    // MFAを新たに登録するための画面へ遷移
    router.push("/(auth)/enroll-mfa");
    return;
  }
  if (data.nextLevel === "aal2") {
    // MFAを認証するための画面へ遷移
    router.push("/(auth)/challenge-mfa");
    return;
  }
}
```

# MFAの登録

MFA登録の際は```mfa.enroll```メソッドを使用し、登録用のQRコードをSVG形式で返しますのでアプリでQRコードを表示します。

```jsx:MFA登録
export async function enrollMFA() {
  const { data } = await supabase.auth.mfa.enroll({
    factorType: "totp",
  });
  return {
    qr: data.totp.qr_code
  };
}
```

Expoでは```react-native-svg```を使用してSVGを表示することができます。

https://www.npmjs.com/package/react-native-svg

```例：<SvgXml xml={qr} />```

ユーザーは認証アプリ（google authenticator等）でQRコードをスキャンし、登録のためのコードを取得することができます。

その後、`mfa.challenge()` APIを使用してチャレンジを作成し、ユーザーが入力したコードを`mfa.verify()`を使用して検証します。

```jsx:MFA検証（登録時）
export async function verifyEnrollMFA(factorId: string, code: string) {
  const challenge = await supabase.auth.mfa.challenge({ factorId });
  const challengeId = challenge.data.id;
  const verify = await supabase.auth.mfa.verify({
    factorId,
    challengeId,
    code,
  });
}
```

これにて登録完了で、再度ログイン時には以下の状況になり、MFAの検証を求められます、
| 現在のレベル | 次のレベル | 意味 |
|------------|----------|------|
| aal1       | aal2     | ユーザーはMFA要素を登録していますが、検証していません。 |

# MFAの検証
続いて、既にMFA登録済みでのログイン時のMFA検証処理です。
アプリ側では認証アプリで表示されているコードの入力を促して以下のように処理を実装します。

該当のユーザーのMFA要素のIDを```mfa.listFactors```メソッドを使用し取得し、
```mfa.challenge```と```mfa.verify```を使用することで検証をします。

```jsx:MFA検証（ログイン時）
export async function verifyMFA(code: string) {
  const factors = await supabase.auth.mfa.listFactors();
  const totpFactor = factors.data.totp[0];
  if (!totpFactor) {
    // TOTPが見つからない場合、エラーメッセージを表示
    return false;
  }
  // TOTPのIDを取得
  const factorId = totpFactor.id;
  
  const { data } = await supabase.auth.mfa.challenge({ factorId });
  const challengeId = data.id;
  // ユーザーが入力したコードを検証
  const verify = await supabase.auth.mfa.verify({
    factorId,
    challengeId,
    code,
  });
  return true;
};
```

これにて、MFAの実装も完了になります。

# 終わりに
ネイティブアプリ開発では過去にFlutter×firebaseは実装したことがありましたが、Expo RouterやExpo Goにより開発体験は非常に良かった印象です。
Supabaseとの連携により認証周りも簡単に実装することができました。

# 参考文献
https://expo.dev/blog/why-expo-is-a-great-fit-for-new-and-existing-react-native-apps#conclusion

https://docs.expo.dev/guides/using-supabase/

https://supabase.com/docs/guides/auth