React(Next.js)のStatic Exports設定で認証ロジックを実装する④

[株式会社 ONE WEDGE](https://onewedge.co.jp/)の[@Shankou](https://qiita.com/Shankou)です。
前回に引き続き、認証ロジックの実装を行っていきたいと思います。

今回はアクセス制限の実装に手をつけていきます。

# src/features/Auth/hooks/useRefreshToken.ts
最初にRefreshTokenとAccessTokenを扱うhooksを作成します。
まずは`src/features/Auth/hooks/useRefreshToken.ts`を作成していきます。

今回のポイントとしては`useSyncExternalStore`を使っていることでしょうか。
こちらは、React外部のデータソースのストアを監視し、その変更を検知することができるようになるというものです。
もっと分かりやすくいうと`localStorage`と`sessionStorage`の変更を検知する`React hooks`が作成できます。
タネは意外と簡単で更新時に登録したイベントを発火させることでReactがそれを拾ってくれる形ですね。

一点注意が必要なのは、`useRefreshToken`は`React Component`の中からしか呼び出しができないことです。
通常のアクセスは`getRefreshToken`関数や`setRefreshToken`関数から行ってください。

```typescript:src/features/Auth/hooks/useRefreshToken.ts
import { useSyncExternalStore } from 'react'

const EVENT_NAME = 'updaterefreshtoken'
const subscribe = (callback: () => void) => {
  window.addEventListener(EVENT_NAME, callback)
  return () => {
    window.removeEventListener(EVENT_NAME, callback)
  }
}

export const REFRESH_TOKENE_KEY_NAME = 'refreshToken'

export const getRefreshToken = () => {
  if (typeof window !== 'undefined') {
    return window.localStorage.getItem(REFRESH_TOKENE_KEY_NAME) || ''
  }
  return ''
}

export const setRefreshToken = (token: string) => {
  if (typeof window !== 'undefined') {
    window.localStorage.setItem(REFRESH_TOKENE_KEY_NAME, token)
    // RefreshTokenの更新イベントを発火させる
    window.dispatchEvent(new Event(EVENT_NAME))
  }
}

export const useRefreshToken = () => {
  const refreshToken = useSyncExternalStore(
    subscribe,
    () => getRefreshToken(),
    () => ''
  )

  return { refreshToken, setRefreshToken }
}
```


# src/features/Auth/hooks/useAccessToken.ts
`useRefreshToken`とほぼ同じになります。

```typescript:src/features/Auth/hooks/useAccessToken.ts
import { useSyncExternalStore } from 'react'

const EVENT_NAME = 'updateaccesstoken'
const subscribe = (callback: () => void) => {
  window.addEventListener(EVENT_NAME, callback)
  return () => {
    window.removeEventListener(EVENT_NAME, callback)
  }
}

export const ACCESS_TOKEN_KEY_NAME = 'accessToken'

export const getAccessToken = () => {
  if (typeof window !== 'undefined') {
    return window.sessionStorage.getItem(ACCESS_TOKEN_KEY_NAME) || ''
  }
  return ''
}

export const setAccessToken = (token: string) => {
  if (typeof window !== 'undefined') {
    window.sessionStorage.setItem(ACCESS_TOKEN_KEY_NAME, token)
    // AccessTokenの更新イベントを発火させる
    window.dispatchEvent(new Event(EVENT_NAME))
  }
}

export const useAccessToken = () => {
  const accessToken = useSyncExternalStore(
    subscribe,
    () => getAccessToken(),
    () => ''
  )

  return { accessToken, setAccessToken }
}
```

# src/app/()/login/page.tsx
というわけで、前回作成した`src/app/()/login/page.tsx`を改修して、`setAccessToken`と`setRefreshToken`を適用していきます

```typescript
// sessionStorage.setItem('accessToken', result.accessToken) => setAccessToken(result.accessToken)
setAccessToken(result.accessToken)
// localStorage.setItem('refreshToken', result.refreshToken) => setRefreshToken(result.refreshToken)
setRefreshToken(result.refreshToken)
```

```react:src/app/()/login/page.tsx
'use client'

import CustomInputField from '@/components/elements/CustomInputField'
import { setAccessToken } from '@/features/Auth/hooks/useAccessToken'
import { setRefreshToken } from '@/features/Auth/hooks/useRefreshToken'
import { PostLoginRequest, zPostLoginRequest } from '@/features/Auth/types/request/PostLoginRequest.type'
import { PostLoginResponse } from '@/features/Auth/types/response/PostLoginResponse.type'
import { useRouter } from 'next/navigation'
import { ReactElement } from 'react'
import { useForm, SubmitHandler } from 'react-hook-form'

export default function Login(): ReactElement {
  const route = useRouter()
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<PostLoginRequest>()
  const onSubmit: SubmitHandler<PostLoginRequest> = async (data) => {
    try {
      const params: PostLoginRequest = zPostLoginRequest.parse(data)
      /** 本来であればここでAPIを呼び出します **/
      // const result: PostLoginResponse | false = await myAxios.post('/login', params)
      //   .then((res) => zPostLoginResponse.parse(res))
      //   .catch(() => false)

      // if (result === false) {
      //   // APIエラー処理
      //   return
      // }

      /** 今回はフロント周りの動作のみのため、一旦適当なデータを用意 **/
      const result: PostLoginResponse = { accessToken: 'hogehoge', refreshToken: 'fugafuga' }
      
      setAccessToken(result.accessToken)
      setRefreshToken(result.refreshToken)
      
      route.push('/mypage')
    } catch (e) {
      console.error(e)
      // エラー処理
    }
  }

  return (
    <>
      <h1>ログイン</h1>
      <div className='flex justify-center'>
        <form className='m-2 w-96' onSubmit={handleSubmit(onSubmit)}>
          <CustomInputField
            type='email'
            id='login-id'
            label='メールアドレス'
            className='mt-2'
            error={errors.email?.message}
            {...register('email', {
              required: 'メールアドレスを入力してください',
            })}
          />
          <CustomInputField
            type='password'
            autoComplete='current-password'
            minLength={8}
            id='password'
            label='パスワード'
            className='mt-2'
            error={errors.password?.message}
            {...register('password', {
              required: 'パスワードを入力してください',
              minLength: {
                value: 8,
                message: 'パスワードは8文字以上で入力してください',
              },
            })}
          />
          <button type='submit' className='mt-6 p-4 w-full border bg-slate-200/60'>ログイン</button>
        </form>
      </div>
    </>
  )
}
```

# src/features/Auth/types/AuthenticationStatus.type.ts
それでは、`AuthProvider`の前に認証ステータス状態を表す定義ファイルを作成します。

```typescript:src/features/Auth/types/AuthenticationStatus.type.ts
export const Waiting = Symbol('AUTHENTICATION_STATUS: WAITING')
export const Processing = Symbol('AUTHENTICATION_STATUS: PROCESSING')
export const Failed = Symbol('AUTHENTICATION_STATUS: FAILED')
export const Successful = Symbol('AUTHENTICATION_STATUS: SUCCESSFUL')

export type AuthenticationStatus = typeof Waiting | typeof Processing | typeof Failed | typeof Successful
```

# src/features/Auth/providers/AuthProvider.tsx
こちらが認証状態を常時監視してステータスを通知してくれる`AuthProvider.tsx`になります。

```react:src/features/Auth/providers/AuthProvider.tsx
'use client'

import { ReactNode, createContext, useState, useEffect, useContext } from 'react'

import {
  AuthenticationStatus,
  Waiting,
  Processing,
  Failed,
  Successful,
} from '@/features/Auth/types/AuthenticationStatus.type'
import { usePathname, useSearchParams } from 'next/navigation'
import { useAccessToken } from '@/features/Auth/hooks/useAccessToken'
import { useRefreshToken } from '@/features/Auth/hooks/useRefreshToken'

const AuthenticationStatusContext = createContext<AuthenticationStatus>(Waiting)
export function useAuthenticationStatusContext() {
  return useContext(AuthenticationStatusContext)
}

type AuthProviderProps = {
  children: ReactNode
}
export default function AuthProvider({ children }: AuthProviderProps) {
  const pathname = usePathname()
  const searchParams = useSearchParams()
  const [authenticationStatus, setAuthenticationStatus] = useState<AuthenticationStatus>(Waiting)
  const { accessToken } = useAccessToken()
  const { refreshToken } = useRefreshToken()
  let status = authenticationStatus

  // 認証確認処理
  useEffect(() => {
    if (status !== Processing) {
      setAuthenticationStatus((status = Waiting))
    }

    if (status === Waiting) {
      setAuthenticationStatus((status = Processing))
      ; (async () => {
        // accessTokenの有効期間を確認して、必要であればrefreshTokenを用いて更新を行う (後ほど実装)
        // await refreshIfExpired()
        if (refreshToken && accessToken) {
          setAuthenticationStatus((status = Successful))
        } else {
          setAuthenticationStatus((status = Failed))
        }
      })()
    }
  }, [
    // pathname & searchParamsを依存関係に含めることでページ遷移ごとに
    // accessToken & refreshTokenを依存関係に含めることで各Tokenの更新ごとに認証確認処理を実行させる
    pathname, searchParams, accessToken, refreshToken
  ])

  return (
    <AuthenticationStatusContext.Provider value={authenticationStatus}>
      {children}
    </AuthenticationStatusContext.Provider>
  )
}
```

# src/app/layout.tsx
作成した`AuthProvider.tsx`を導入していきます。
`layout.tsx`に組み込んで行きましょう。
注意事項としては`layout.tsx`に`React hooks`は入れられないという点です。
使いたい場合はどうすればいいのか、解決方法は次項で説明します。

```react:src/app/layout.tsx
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import { Suspense } from 'react'
import AuthProvider from '@/features/Auth/providers/AuthProviders'

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
  title: '',
  description: '',
}

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang='ja'>
      <body className={`min-h-screen bg-white ${inter.className}`}>
        <Suspense>
          <AuthProvider>
            {children}
          </AuthProvider>
        </Suspense>
      </body>
    </html>
  )
}
```

# src/app/(authenticated)/template.tsx
前項の解決方法がこちら！
`template.tsx`という特殊存在です。`layout.tsx`と`page.tsx`の間へ自動的に組み込まれるので、明示的にインポートする必要はありません。
`src/app/(authenticated)`に配置した`template.tsx`なので、認証済みのページにアクセスしようとした場合にのみ適用されます。
`layout.tsx`レベルで組み込みたい処理はこちらに記述するといいでしょう。

今回は`useAuthenticationStatusContext`を用いて認証状態を確認し、未認証状態ならログインページに転送するという処理を行っています。

```react:src/app/(authenticated)/template.tsx
'use client'

import { useAuthenticationStatusContext } from '@/features/Auth/providers/AuthProviders'
import { Failed } from '@/features/Auth/types/AuthenticationStatus.type'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'

type TemplateProps = {
  children: React.ReactNode
}

export default function Template({ children }: TemplateProps) {
  const router = useRouter()
  const authenticationStatus = useAuthenticationStatusContext()

  useEffect(() => {
    switch (authenticationStatus) {
      case Failed:
        router.push('/login')
        break
      default:
        break
    }
  }, [authenticationStatus])

  return (
    <>{children}</>
  )
}
```

# 実はまだ続く
当初の目的であった認証状態でアクセス制限を行うロジックは概ね組めました。
ですが、もう少しだけ調整したい箇所が残っています。
APIの呼び出し時に自動でTokenを組み込んだりとか、refreshIfExpired関数とか、、、

それはまた次回ということで今回は締めたいと思います。

next > [⑤](https://qiita.com/Shankou/items/eedc68434b0407e5bf88)