Nuxt で認証基盤作成のポイント

## はじめに
Nuxt v3 以降でメールアドレス・パスワードにおける認証基盤の作成は難しいです。
SPA ならまだ Client Only なので、少しでも楽ですが、SSR だとそうはいきません。
また、Nuxt の公式でも推奨している認証パッケージ [sidebase/nuxt-auth](https://github.com/sidebase/nuxt-auth) がありますが、認証保持に不具合が今もあったりします。
例えば、Cookie の有効期限切れgトリガーされず、ずっと認証状態が保存されたままなどです。

該当 issue: https://github.com/sidebase/nuxt-auth/issues/1006
（記事執筆時点（2025/04/13）での issue になりますので、もしかすると修正されている可能性もあったりします）

## 独自の認証基盤作成
`sidebase/nuxt-auth` に不具合があるため、認証パッケージの使用は避ける必要がありました。
その他の認証パッケージもうまく使用できる状態のものがありませんでした。
そのため、sidebase/nuxt-auth のソースを参考にしつつ、こちらのアプリケーションのビジネス要件に合わせた実装をする必要がありました。

## 認証基盤作成のポイント
まず、ポイントを下記に示します。

* composables に認証基盤を作成する
* cookie 情報は [useCookie](https://nuxt.com/docs/api/composables/use-cookie) を使用する
* cookie の状態保存ように [useState](https://nuxt.com/docs/api/composables/use-state) を使用する
* cookie の有効期限が切れたことを検知するよう、useCookie の値を watch しておく
* cookie の値を連携する（v3.16.2 から不要）

### composables に認証基盤を作成する
こちらは、他の認証基盤に合わせて composables に作成する形をとります。
シンプルに `composables/useSession.ts` 作成します。

### cookie 情報は [useCookie](https://nuxt.com/docs/api/composables/use-cookie) を使用する
認証状態は useCookie を使用します。
個人的なポイントとしては、変数は composables だけで参照させたいため、`_` 始まりとしています。

```ts:composables/useSession.ts
  const _token = useCookie<string | null | undefined>('auth._token.user', {
    sameSite: 'lax',
    watch: true,
    secure: true,
    readonly: false,
    maxAge: 60 * 60 * 3
  })
```

### cookie の状態保存ように [useState](https://nuxt.com/docs/api/composables/use-state) を使用する
こちらは、結構ハマったとこでした。
useCookie で認証情報を保存して、画面操作などは問題なくできるんですが、別ブラウザ等でログインして、元のブラウザでリロードするとおかしな挙動をしたりしました。（問題の詳細は思い出せず・・・）
そのため、useCookie の値を useState でも保存するようにしました。
そうすることにより、各ブラウザ単位で状態を保持できるようになります。
ちなみに、この useState のソースの追加は `sidebase/nuxt-auth` を参考にしました。

```ts:composables/useSession.ts
  const _tokenState = useState<string | null>(() => {
    return _token.value ?? null
  })
```

そして、その state を算出プロパティでリアルタイムに更新された値を返却できるよう定義します。

```ts:composables/useSession.ts
  const token = computed<string | null>(() => {
    return _tokenState.value
  })
```

### cookie の有効期限が切れたことを検知するよう、useCookie の値を watch しておく
maxAge が過ぎた際、useCookie で定義された変数の値は undefined になります。
その変更を state 側にも連携する必要があるため、cookie の値を watch しておきます。

```ts:composables/useSession.ts
  watch(
    () => _token.value,
    (newTokenValue) => {
      if (newTokenValue === undefined) {
        _tokenState.value = null
        return
      }
      _tokenState.value = newTokenValue
    }
  )
```

### client 側の cookie の値を連携する（v3.16.2 から不要）
client 側の cookie 情報をユーザー側で意図的に削除した際、useCookie に変更がトリガーされません。
そのため、画面遷移する際や認証情報が必要な API 通信をする前に useCookie へ値を連携するようにロジックを記載します。

```ts:composables/useSession.ts
  const syncAuthTokens = async (ignorePath: string): Promise<void> => {
    if (import.meta.server || [/* 連携してほしくないページパスの配列 */].includes(ignorePath)) {
      return
    }

    const { cookie } = document
    const splitCookies = cookie.split(';').map((c) => {
      return c.trim()
    })

    const findCookieValue = (key: string): string | undefined => {
      const cookieItem = splitCookies.find((value) => {
        return value.startsWith(`${key}=`)
      })
      return cookieItem !== undefined ? decodeURI(cookieItem.split('=')[1]) : undefined
    }

    const tokenValue = findCookieValue('auth._token.user')

    if (tokenValue !== undefined) {
      _tokenState.value = tokenValue
      _token.value = tokenValue
    } else {
      _token.value = undefined
      _tokenState.value = null
    }
  }
```

上記処理は Nuxt v3.16.2 から `document.cookie` の値が初期値としても連携されるような修正が入ったため、そのバージョンを使用していれば、不要な処理となります。

参考： https://github.com/nuxt/nuxt/releases/tag/v3.16.2

### ここまでのソースをまとめてみる

```ts:composables/useSession.ts
export const useSession = () => {
  const _token = useCookie<string | null | undefined>('auth._token.user', {
    sameSite: 'lax',
    watch: true,
    secure: true,
    readonly: false,
    maxAge: 60 * 60 * 3
  })

  const _tokenState = useState<string | null>(() => {
    return _token.value ?? null
  })

  const token = computed<string | null>(() => {
    return _tokenState.value
  })

  watch(
    () => _token.value,
    (newTokenValue) => {
      if (newTokenValue === undefined) {
        _tokenState.value = null
        return
      }
      _tokenState.value = newTokenValue
    }
  )

  // ログイン処理
  const signIn = () => {}

  // アカウント作成処理
  const signUp = () => {}

  // ログアウト処理
  const signOut = () => {}

  const syncAuthTokens = async (ignorePath: string): Promise<void> => {
    if (import.meta.server || [/* 連携してほしくないページパスの配列 */].includes(ignorePath)) {
      return
    }

    const { cookie } = document
    const splitCookies = cookie.split(';').map((c) => {
      return c.trim()
    })

    const findCookieValue = (key: string): string | undefined => {
      const cookieItem = splitCookies.find((value) => {
        return value.startsWith(`${key}=`)
      })
      return cookieItem !== undefined ? decodeURI(cookieItem.split('=')[1]) : undefined
    }

    const tokenValue = findCookieValue('auth._token.user')

    if (tokenValue !== undefined) {
      _tokenState.value = tokenValue
      _token.value = tokenValue
    } else {
      _token.value = undefined
      _tokenState.value = null
    }
  }

  return {
    token,
    signIn,
    signUp,
    signOut,
    syncAuthTokens
  }
}
```

他の signIn や signUp などの必要な関数は **みなさんのビジネス要件に合わせた実装が必要** になります。

## まとめ
ここまでで、認証基盤作成におけるポイントを紹介しました。
今の所、認証がらみのエラーは出ていません。（自分が担当させていただいてるアプリケーションは大量のトラフィックとなるため、もしかしたらエラーは出てないと断言できない可能性もありますが、インシデントになるようなエラーはありません）
Nuxt v3 系が出てから、結構な日付が経ちますが今もいい認証パッケージがないのはつらみですね・・・
ただ、今の認証は Google などの外部プロバイダーを使用したものが多いため、本記事にあるような悩みを持っている方は少ないかもしれませんが、もし誰かの役に立てていただければ幸いです。🙏

それでは、最後までご愛読いただきありがとうございました！