認証トークン管理
14.4.1. JWT保存戦略
- Access Tokenは**メモリ(Pinia Store)**に保存します。
localStorageはXSS(Cross-Site Scripting)攻撃に脆弱であるため使用しません。 - Refresh TokenはHttpOnly Cookieで管理します。クライアントJavaScriptからアクセスできないようにしなければなりません。
| トークン種別 | 保存場所 | 根拠 |
|---|---|---|
| Access Token | Pinia Store(メモリ) | XSS攻撃時の窃取防止 |
| Refresh Token | HttpOnly Cookie | JavaScriptアクセス遮断 |
- Auth Store(
src/stores/useAuthStore.ts)にaccessToken状態とsetAccessToken、clearAccessTokenアクションを定義します。ページリロード時にメモリ上のトークンが消失するため、アプリ初期化時点で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呼び出し | 無視して次のステップに進む |
| 2 | Access 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が失敗した場合でも、クライアント側のクリーンアップ処理は必ず完了しなければなりません。