Skip to main content

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

TokenStorageLifetimeSecurity
Access TokenMemory (Pinia)15-30 minNot accessible by XSS
Refresh TokenHttpOnly Cookie7-30 daysNot accessible by JS

Auth Plugin Implementation

// 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

// 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

<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:
// Public API call
const publicData = await $fetch('/api/public/health')

Token Refresh Flow

Error Handling

API Error Types

// 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

// 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

const response = await $api('/api/v1/inventory', {
  headers: {
    'X-Custom-Header': 'value',
  },
})

With Query Parameters

const response = await $api('/api/v1/inventory', {
  query: {
    page: 1,
    limit: 20,
    search: 'laptop',
  },
})

POST with Body

const response = await $api('/api/v1/inventory', {
  method: 'POST',
  body: {
    name: 'New Product',
    price: 100000,
  },
})

File Upload

const formData = new FormData()
formData.append('file', file)

const response = await $api('/api/v1/upload', {
  method: 'POST',
  body: formData,
})

Timeout and Retry

// 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

// 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:
// 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
Ini ditangani oleh refreshPromise deduplication:
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

// Good - uses auth interceptor
const data = await $api('/api/v1/inventory')

// Bad - no auth header
const data = await $fetch('/api/v1/inventory')
// 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
// Good - typed response
const product = await $api<Product>('/api/v1/products/123')

// Avoid - untyped response
const product = await $api('/api/v1/products/123')
// Required for HttpOnly cookies
options.credentials = 'include'

Next Steps