Skip to content

認証トークン管理

14.4.1. JWT保存戦略

  • Access Tokenは**メモリ(Pinia Store)**に保存します。localStorageはXSS(Cross-Site Scripting)攻撃に脆弱であるため使用しません。
  • Refresh TokenはHttpOnly Cookieで管理します。クライアントJavaScriptからアクセスできないようにしなければなりません。
トークン種別保存場所根拠
Access TokenPinia Store(メモリ)XSS攻撃時の窃取防止
Refresh TokenHttpOnly CookieJavaScriptアクセス遮断
  • Auth Store(src/stores/useAuthStore.ts)にaccessToken状態とsetAccessTokenclearAccessTokenアクションを定義します。ページリロード時にメモリ上のトークンが消失するため、アプリ初期化時点でRefresh Tokenを利用して再発行しなければなりません。

14.4.2. トークン更新

  • 401レスポンス受信時、Refresh Tokenを利用してAccess Tokenの更新を自動的に試みます。
  • 更新リクエスト中に発生する追加リクエストはキューに保存し、更新完了後に順次リトライします。
  • 更新リクエスト自体が失敗した場合、キューに保存されたすべてのリクエストを拒否し、ログアウト処理を実行します。
typescript
// src/api/interceptors/tokenRefresh.ts
import type { AxiosError, InternalAxiosRequestConfig } from 'axios'
import apiClient from '@/api/client'
import { useAuthStore } from '@/stores/useAuthStore'

let isRefreshing = false
let pendingRequests: Array<{ resolve: Function; reject: Function }> = []

export async function handleTokenRefresh(error: AxiosError): Promise<unknown> {
  const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
  if (error.response?.status !== 401 || originalRequest._retry) {
    return Promise.reject(error)
  }

  if (isRefreshing) {
    return new Promise((resolve, reject) => {
      pendingRequests.push({ resolve, reject })
    })
  }

  originalRequest._retry = true
  isRefreshing = true

  try {
    const { data } = await apiClient.post<{ accessToken: string }>('/auth/refresh')
    useAuthStore().setAccessToken(data.accessToken)
    pendingRequests.forEach(({ resolve }) => resolve(apiClient(originalRequest)))
    pendingRequests = []
    originalRequest.headers.Authorization = `Bearer ${data.accessToken}`
    return apiClient(originalRequest)
  } catch (refreshError) {
    pendingRequests.forEach(({ reject }) => reject(refreshError))
    pendingRequests = []
    useAuthStore().clearAccessToken()
    window.location.href = '/login'
    return Promise.reject(refreshError)
  } finally {
    isRefreshing = false
  }
}

14.4.3. 401自動リダイレクト

  • トークン更新が失敗した場合、ユーザーをログインページにリダイレクトします。
  • リダイレクト時、現在のページパスをreturnUrlクエリパラメータとして保持します。
  • returnUrlは必ず/で始まるパスであることを検証します。外部URLへのリダイレクトを防止しなければなりません。
typescript
// src/api/utils/redirect.ts
import router from '@/router'

export function redirectToLogin(): void {
  const currentPath = router.currentRoute.value.fullPath
  if (currentPath === '/login') return
  router.push({ path: '/login', query: { returnUrl: currentPath } })
}
  • ログイン成功後、returnUrlクエリを読み取り、該当パスに復帰します。returnUrl/で始まることを必ず検証します。

14.4.4. ログアウト処理

  • ログアウト時、以下の項目を順次処理します。
順序処理項目失敗時の動作
1サーバーログアウトAPI呼び出し無視して次のステップに進む
2Access Token削除必須(常に実行)
3ユーザーStoreの初期化必須(常に実行)
4ログインページへのリダイレクト必須(常に実行)
typescript
// src/composables/useLogout.ts
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/useAuthStore'
import { useUserStore } from '@/stores/useUserStore'
import apiClient from '@/api/client'

export function useLogout() {
  const router = useRouter()
  async function logout(): Promise<void> {
    try { await apiClient.post('/auth/logout') } catch { /* 失敗を無視 */ }
    useAuthStore().clearAccessToken()
    useUserStore().$reset()
    await router.push('/login')
  }

  return { logout }
}
  • サーバーログアウトAPIが失敗した場合でも、クライアント側のクリーンアップ処理は必ず完了しなければなりません。

TIENIPIA QUALIFIED STANDARD