Nuxt3 + Firebase Authentication で簡単な認証を試してみる

# はじめに

インストール方法などは[こちら](https://qiita.com/tauemo/items/bd5bc0f91faaf731b975)をご確認ください。ここでは、Nuxt3のmiddleware、pluginsをFirebase Authenticationを利用しながら理解を深めることを目的としています。

# Firebase Authentication を設定

① Sign-in methodログインプロバイダでとりあえず、メールとパスワードによるユーザー認証を有効にする

② プロジェクトの設定で、webアプリを追加する。firebaseConfigというjsonが表示されるので必要に応じてここを参照する

③ Userのメールアドレスとパスワードを登録する

※ Firebaseで認証するとIndexedDB（モダンブラウザにはこういったDBがあります）にログインしたユーザー情報が保存される。
・・・明示的に消さないと消えないはずなので、ログインしっぱなしになるってことなのかな？ログイン時間は自分たちで管理して、削除する必要があるってことか？
ちゃんと調べなくては・・・。

## 開発

`npm install --save firebase` でパッケージインストール

### plugins

Firebaseの初期化をpluginsで行う。

pluginsは、「プラグインファイルを作成するとアプケーションの起動時に自動で登録を行ってくれるため登録作業を行う必要がありません。」

ファイル名にclientをつけることでクライアントのみで使用することができます。

plugins/firebase.client.ts

```typescript
import { initializeApp } from 'firebase/app'
import { defineNuxtPlugin } from '#app'

export default defineNuxtPlugin(() => {
  const config = useRuntimeConfig();

  const firebaseConfig = {
    apiKey: config.FIREBASE_API_KEY,
    authDomain: config.FIREBASE_AUTH_DOMAIN,
    projectId: config.FIREBASE_PROJECT_ID,
  };

  initializeApp(firebaseConfig);
})
```

### composables作成

getAuth()でauthを取得して、credentialを取得して、tokenを獲得して設定するsign in

getAuth()でauthを取得して、firebase から sign outしてtokenを削除するsign out

これだけで最低限の動作はする。

composables に states.ts を作成して一元管理するのが良さそうなのでこちらにuseTokenをていぎします。

states.ts
```typescript
export const useToken = (): globalThis.Ref<string | null> =>
  useState<string | null>('token', () => null);
```

typescriptぽく型定義もしっかりしてみましたが、これは好みやプロジェクトの方針によって異なってよいと思います。ただし、やるならとことんやる。やらないならやらない。混在していることが初学者を混乱させます。classにするのか関数にするのかとかも。

```typescript
import {
  getAuth,
  signInWithEmailAndPassword,
  signOut as firebaseSignOut,
  onAuthStateChanged,
} from 'firebase/auth'

type Auth = {
  token: globalThis.Ref<string | null>;
  signIn: (email: string, password: string) => Promise<void>;
  signOut: () => Promise<void>;
  checkAuthState: () => void;
};

export const useAuth = (): Auth => {
  const token = useToken();

  const signIn = async (email: string, password: string): Promise<void> => {
    const auth = getAuth();
    const userCredential = await signInWithEmailAndPassword(auth, email, password);
    const idToken = await userCredential.user.getIdToken();
    token.value = idToken;
  };

  const signOut = async (): Promise<void> => {
    const auth = getAuth();
    await firebaseSignOut(auth);
    token.value = null;
  };

  const checkAuthState = (): void => {
    // serverからは利用できなくします
    if (process.server) return;
    const auth = getAuth();
    onAuthStateChanged(auth, async (user) => {
      if(user) {
        const idToken = await user.getIdToken();
        token.value = idToken;
      } else {
        token.value = null;
      }
    });
  };

  return {
    signIn,
    signOut,
    token,
    checkAuthState,
  }
}
```

### middlewareをグローバルで作成

globalで作成すると全ページでこれが呼ばれるので、無限ループが発生します。認証が必要ないページのパスをここに定義し、early returnします。

※ early return には賛否両論あるのですが、個人的には好きなので使います。

middleware/auth.global.ts

```typescript
import { RouteLocationNormalized } from "vue-router";

export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized) => {
  // loginページの場合なにもしません
  if(to.path == '/login') return;

  const { checkAuthState, token } = useAuth();
  await checkAuthState();

  // tokenがなければログインページにリダイレクト
  if (!token.value) return await navigateTo('/login', { replace: true });
});
```

### loginページ作成

ログインと言ってみたりサインインと言ってみたり。ごめんなさい違いをよく分かってません。

簡単に作成してます。とりあえず、どんなものか作ってみたって感じです。

pages/login.vue

```vue
<script setup lang="ts">
  const email: Ref<string> = ref('');
  const password: Ref<string> = ref('');

  const signIn = async (): Promise<void> => {
    await useAuth().signIn(email.value, password.value);
    if (useAuth().token.value) {
      navigateTo('/');
    }
  }
</script>
<template>
  <div>
    <label>メールアドレス<input type="text" v-model="email" placeholder="email" name="email" /></label>
    <label>パスワード<input type="password" v-model="password" placeholder="password" name="password" /></label>
    <button @click="signIn">Sign In</button>
  </div>
</template>
```

### google認証を追加

googleの認証を追加します。twitterやfacebookなどもなんとなくで分かると思うのでgoogleだけ試します。

firebaseのSign-in methodログインプロバイダでGoogle認証を有効にする

composablesにgoogleを追加します。もっと無駄のない（繰り返しのない）プログラムを書けますが、本ドキュメントの可読性を優先します。

（個人的には抽象化したり、継承したりすることでシンプルなプログラムになって変化に強くなるとは思うのですが、可読性は落ちると考えています。
プロジェクト内のスキルに合わせてどのようにコーディングしていくか決めるのが良いと思います。
ステップ数が多すぎたりネストが深かったりすると読む気なくすんですけどね・・・）

```typescript
import {
  getAuth,
  signInWithEmailAndPassword,
  signOut as firebaseSignOut,
  onAuthStateChanged,
  GoogleAuthProvider,
  signInWithPopup,
} from 'firebase/auth'

type Auth = {
  token: globalThis.Ref<string | null>;
  signIn: (email: string, password: string) => Promise<void>;
  signOut: () => Promise<void>;
  checkAuthState: () => void;
  signInWithGoogle: () => Promise<void>;
};

export const useAuth = (): Auth => {
  const token = useToken();

  const signIn = async (email: string, password: string): Promise<void> => {
    const auth = getAuth();
    const userCredential = await signInWithEmailAndPassword(auth, email, password);
    const idToken = await userCredential.user.getIdToken();
    token.value = idToken;
  };

  const signOut = async (): Promise<void> => {
    const auth = getAuth();
    await firebaseSignOut(auth);
    token.value = null;
  };

  const checkAuthState = (): void => {
    // serverからは利用できなくします
    if (process.server) return;
    const auth = getAuth();
    onAuthStateChanged(auth, async (user) => {
      if(user) {
        const idToken = await user.getIdToken();
        token.value = idToken;
      } else {
        token.value = null;
      }
    });
  };

  // Google
  const signInWithGoogle = async (): Promise<void> => {
    const provider = new GoogleAuthProvider();
    const auth = getAuth();
    const userCredential = await signInWithPopup(auth, provider);
    const idToken = await userCredential.user.getIdToken();
    token.value = idToken;
  }

  return {
    signIn,
    signOut,
    token,
    checkAuthState,
    signInWithGoogle,
  }
}
```

ページ側にもgoogleログインボタンを付けます。

```vue
<script setup lang="ts">
  const email: Ref<string> = ref('');
  const password: Ref<string> = ref('');

  const signIn = async (): Promise<void> => {
    await useAuth().signIn(email.value, password.value);
    if (useAuth().token.value) {
      navigateTo('/');
    }
  }
  const signInGoogle = async () => {
    await useAuth().signInWithGoogle();
    if (useAuth().token.value) {
      navigateTo('/');
    }
  }
</script>
<template>
  <div>
    <label>メールアドレス<input type="text" v-model="email" placeholder="email" name="email" /></label>
    <label>パスワード<input type="password" v-model="password" placeholder="password" name="password" /></label>
    <button @click="signIn">Sign In</button>
    <button @click="signInGoogle">Google Sign In</button>
  </div>
</template>
```

# 要検討

ここまでで簡単な動作確認はできたと思います。

ただし、Firebase Authenticationはtokenをブラウザのindexeddbに保存しているため、クライアント側だけでトークンを利用するのか、Cookie(httpOnly)を使ってサーバー側でトークンを利用するのかをしっかり検討しないといけない。

上記サンプルの場合、中途半端な作りになっていて、middlewareをグローバルで作成してサーバー側で処理しています。
せっかくのログインが永続化できておらず、URL直打ちとかでログイン画面に飛ばされてしまいます。

今回はサンプルと言うことで割愛します。

# メモ

実運用では、上記プログラムのuserCredentialで取得したuidをDBか何かに保存して、自分たちでユーザー情報は持つ。

このuidは暗号化すべきかどうか・・・。

もちろん、そのサイトが使っているユーザーIDとは別のものとして保存する。
このユーザーIDは連番になってると嫌がられるのでUUIDにするんだけど、そうするとカラム名がuidとuuidの二つあってめっちゃ気持ち悪い。
ネーミングセンス重要。

firebase-adminを使って、getIdTokenで取得したtokenからuidを取得する。
なんで、firebase-adminという別パッケージなんだろうか。

```typescript
import { auth as adminAuth } from 'firebase-admin'
const decodedToken = await adminAuth().verifyIdToken('idToken');
const uid = decodedToken.uid;
```

それでも、ソーシャルログインが簡単に実装できるし、自前でログインを実装していく大変さを考えるとIDaaSは便利。
仕事で利用するときは、Firebase Authenticationで良いのかとか色々考えることはあるけれども。