API & Auth Interceptors
MStore Dashboard menggunakan Nuxt’s built-in$fetch dengan custom interceptors untuk menangani JWT authentication, auto token refresh, dan error handling.
Security Architecture
Token Storage Strategy
| Token | Storage | Lifetime | Security |
|---|---|---|---|
| Access Token | Memory (Pinia) | 15-30 min | Not accessible by XSS |
| Refresh Token | HttpOnly Cookie | 7-30 days | Not accessible by JS |
Auth Plugin Implementation
Copy
// plugins/auth.client.ts
export default defineNuxtPlugin(() => {
const authStore = useAuthStore()
const config = useRuntimeConfig()
// Track ongoing refresh to prevent multiple simultaneous refreshes
let refreshPromise: Promise<string> | null = null
// Custom fetch with auth
const apiFetch = $fetch.create({
baseURL: config.public.apiBase,
// Add auth header to every request
onRequest({ options }) {
const token = authStore.accessToken
if (token) {
options.headers = {
...options.headers,
Authorization: `Bearer ${token}`,
}
}
// Include cookies for refresh token
options.credentials = 'include'
},
// Handle response errors
async onResponseError({ response, options }) {
// Only handle 401 errors
if (response.status !== 401) {
throw createError({
statusCode: response.status,
message: response._data?.message || 'Request failed',
})
}
// Prevent infinite loop - don't retry refresh endpoint
if (options.url?.includes('/auth/refresh')) {
authStore.logout()
throw createError({
statusCode: 401,
message: 'Session expired. Please login again.',
})
}
// Try to refresh the token
try {
const newToken = await refreshToken()
// Retry the original request with new token
return $fetch(response.url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${newToken}`,
},
})
} catch (error) {
authStore.logout()
navigateTo('/login')
throw error
}
},
})
// Refresh token function with deduplication
const refreshToken = async (): Promise<string> => {
// If refresh is already in progress, wait for it
if (refreshPromise) {
return refreshPromise
}
// Start new refresh
refreshPromise = (async () => {
try {
const response = await $fetch<{ access_token: string }>(
`${config.public.apiBase}/api/v1/auth/refresh-token`,
{
method: 'POST',
credentials: 'include', // Send HttpOnly cookie
}
)
authStore.setToken(response.access_token)
return response.access_token
} finally {
refreshPromise = null
}
})()
return refreshPromise
}
// Provide custom fetch globally
return {
provide: {
api: apiFetch,
},
}
})
Using the API Client
In Composables
Copy
// features/03_inventory/api/getList.ts
export const getInventoryList = async (params?: InventoryQueryParams) => {
const { $api } = useNuxtApp()
return await $api<InventoryResponse>('/api/v1/inventory', {
method: 'GET',
query: params,
})
}
In Components
Copy
<script setup lang="ts">
const { $api } = useNuxtApp()
const fetchData = async () => {
const data = await $api('/api/v1/inventory')
console.log(data)
}
</script>
Direct $fetch (without auth)
Untuk public endpoints yang tidak memerlukan authentication:Copy
// Public API call
const publicData = await $fetch('/api/public/health')
Token Refresh Flow
Error Handling
API Error Types
Copy
// types/api.types.ts
interface ApiError {
statusCode: number
message: string
errors?: Record<string, string[]>
}
interface ValidationError extends ApiError {
statusCode: 422
errors: Record<string, string[]>
}
Handling Errors in Composables
Copy
// features/03_inventory/composables/useInventoryList.ts
export const useInventoryList = () => {
const store = useInventoryStore()
const toast = useToast()
const fetchItems = async () => {
try {
await store.fetchItems()
} catch (error) {
if (error instanceof FetchError) {
switch (error.statusCode) {
case 401:
// Already handled by plugin (redirect to login)
break
case 403:
toast.add({
title: 'Access Denied',
description: 'You do not have permission to view inventory.',
color: 'red',
})
break
case 422:
// Validation error
const errors = error.data?.errors
// Handle validation errors
break
case 500:
toast.add({
title: 'Server Error',
description: 'Please try again later.',
color: 'red',
})
break
default:
toast.add({
title: 'Error',
description: error.message || 'Something went wrong.',
color: 'red',
})
}
}
}
}
return { fetchItems }
}
Request Interceptor Options
Adding Custom Headers
Copy
const response = await $api('/api/v1/inventory', {
headers: {
'X-Custom-Header': 'value',
},
})
With Query Parameters
Copy
const response = await $api('/api/v1/inventory', {
query: {
page: 1,
limit: 20,
search: 'laptop',
},
})
POST with Body
Copy
const response = await $api('/api/v1/inventory', {
method: 'POST',
body: {
name: 'New Product',
price: 100000,
},
})
File Upload
Copy
const formData = new FormData()
formData.append('file', file)
const response = await $api('/api/v1/upload', {
method: 'POST',
body: formData,
})
Timeout and Retry
Copy
// With timeout
const response = await $api('/api/v1/slow-endpoint', {
timeout: 30000, // 30 seconds
})
// With retry
const response = await $api('/api/v1/unstable-endpoint', {
retry: 3,
retryDelay: 1000, // 1 second between retries
})
Response Type Hints
Copy
// Type-safe response
interface Product {
id: string
name: string
price: number
}
const product = await $api<Product>('/api/v1/products/123')
// product is typed as Product
// Array response
const products = await $api<Product[]>('/api/v1/products')
// products is typed as Product[]
// Paginated response
interface PaginatedResponse<T> {
data: T[]
meta: {
total: number
page: number
limit: number
}
}
const response = await $api<PaginatedResponse<Product>>('/api/v1/products')
// response.data is Product[], response.meta is typed
Multiple Simultaneous Requests
Saat ada multiple request bersamaan dan token expired:Copy
// Multiple requests at once
const [products, categories, suppliers] = await Promise.all([
$api('/api/v1/products'),
$api('/api/v1/categories'),
$api('/api/v1/suppliers'),
])
// If token expires during these requests:
// 1. First request gets 401
// 2. Plugin starts refresh
// 3. Other requests wait for refresh
// 4. All requests retry with new token
refreshPromise deduplication:
Copy
let refreshPromise: Promise<string> | null = null
const refreshToken = async (): Promise<string> => {
// If refresh is already in progress, return existing promise
if (refreshPromise) {
return refreshPromise
}
refreshPromise = (async () => {
// ... refresh logic
})()
return refreshPromise
}
Best Practices
Always Use $api for Auth Routes
Always Use $api for Auth Routes
Copy
// Good - uses auth interceptor
const data = await $api('/api/v1/inventory')
// Bad - no auth header
const data = await $fetch('/api/v1/inventory')
Handle Errors at Composable Level
Handle Errors at Composable Level
Copy
// Good - error handling in composable
const fetchItems = async () => {
try {
await store.fetchItems()
} catch (error) {
handleError(error)
}
}
// Avoid - error handling in component
// Component should just call composable
Type Your Responses
Type Your Responses
Copy
// Good - typed response
const product = await $api<Product>('/api/v1/products/123')
// Avoid - untyped response
const product = await $api('/api/v1/products/123')
Use credentials: include
Use credentials: include
Copy
// Required for HttpOnly cookies
options.credentials = 'include'