File size: 5,853 Bytes
b1a388c
 
b3e4ba6
 
 
 
 
 
 
 
 
 
 
 
 
b1a388c
 
b3e4ba6
b1a388c
 
1c2ed83
1e7cc3a
b1a388c
b3e4ba6
b1a388c
 
b3e4ba6
1c2ed83
 
 
 
 
b3e4ba6
b1a388c
 
65d3935
 
 
 
1c2ed83
b1a388c
 
 
1c2ed83
 
 
b3e4ba6
1c2ed83
b1a388c
 
b3e4ba6
b1a388c
 
b3e4ba6
 
 
1c2ed83
b3e4ba6
 
 
1c2ed83
 
b3e4ba6
b1a388c
 
1c2ed83
b3e4ba6
 
 
 
 
 
3b1f979
b3e4ba6
3b1f979
b3e4ba6
 
1c2ed83
b3e4ba6
 
 
 
1c2ed83
 
 
b3e4ba6
 
1c2ed83
b1a388c
b3e4ba6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1c2ed83
b3e4ba6
1c2ed83
 
 
b3e4ba6
 
 
 
 
 
 
 
b1a388c
b3e4ba6
 
 
 
 
 
1c2ed83
b3e4ba6
b1a388c
 
b3e4ba6
 
 
 
 
 
 
e5dee87
b3e4ba6
e5dee87
b3e4ba6
 
e5dee87
 
b3e4ba6
 
 
b1a388c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
/**
 * src/api/client.js
 * ──────────────────
 * Axios instance with automatic token refresh on 401.
 *
 * Auth flow:
 *   1. Every request carries the access token (Authorization: Bearer …)
 *   2. On 401 (expired) → silently POST /api/auth/refresh (sends httpOnly
 *      refresh cookie automatically) → store new access token → retry
 *   3. If refresh also fails → clear session → redirect /login
 *
 * CSRF: double-submit pattern.
 *   The backend sets a readable csrf_token cookie (non-httpOnly, path=/).
 *   We read it and echo it back in X-CSRF-Token on mutating requests.
 *   /auth/refresh and /auth/logout are exempt on the backend side.
 */
import axios from 'axios'
import { clearTokens, getAccessToken, setTokens } from '@/api/tokenStore.js'

const apiClient = axios.create({
  baseURL: '/api',
  timeout: 80000,
  headers: { 'Content-Type': 'application/json' },
  withCredentials: true,   // always send cookies (refresh token, csrf_token)
})

// ── CSRF helper ───────────────────────────────────────────────────────────────
function getCsrfToken() {
  const match = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]+)/)
  return match ? decodeURIComponent(match[1]) : null
}

// ── Request interceptor: attach access token + CSRF ───────────────────────────
apiClient.interceptors.request.use(
  (config) => {
    if (config.data instanceof FormData) {
      delete config.headers['Content-Type']
      delete config.headers['content-type']
    }
    const token = getAccessToken()
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    const method = (config.method || 'get').toLowerCase()
    if (['post', 'put', 'patch', 'delete'].includes(method)) {
      const csrf = getCsrfToken()
      if (csrf) config.headers['X-CSRF-Token'] = csrf
    }
    return config
  },
  (error) => Promise.reject(error),
)

// ── Token refresh state ───────────────────────────────────────────────────────
let _refreshing = false
let _pendingQueue = []   // requests waiting while refresh is in progress

function _flushQueue(error, token = null) {
  _pendingQueue.forEach((p) => (error ? p.reject(error) : p.resolve(token)))
  _pendingQueue = []
}

// ── Response interceptor: handle 401 / 403 ───────────────────────────────────
apiClient.interceptors.response.use(
  (response) => response,
  async (err) => {
    const original = err.config
    const status  = err.response?.status

    // ── Automatic token refresh on 401 ──────────────────────────────────────
    const isRefreshEndpoint = original?.url?.includes('/auth/refresh')
    const isLoginEndpoint   = original?.url?.includes('/auth/login')
    const isGoogleEndpoint  = original?.url?.includes('/auth/google')

    if (status === 401 && original && !original._retry && !isRefreshEndpoint && !isLoginEndpoint && !isGoogleEndpoint) {
      // Queue concurrent requests until refresh completes
      if (_refreshing) {
        return new Promise((resolve, reject) => {
          _pendingQueue.push({ resolve, reject })
        }).then((newToken) => {
          original.headers.Authorization = `Bearer ${newToken}`
          return apiClient(original)
        })
      }

      original._retry = true
      _refreshing = true

      try {
        // Use bare axios so this call never goes through the response interceptor
        // (prevents infinite 401 → refresh → 401 loops)
        const { data } = await axios.post(
          '/api/auth/refresh',
          {},
          { withCredentials: true },
        )
        const newToken = data.access_token
        setTokens(newToken)
        apiClient.defaults.headers.common.Authorization = `Bearer ${newToken}`
        original.headers.Authorization = `Bearer ${newToken}`
        _flushQueue(null, newToken)
        return apiClient(original)
      } catch (refreshError) {
        _flushQueue(refreshError, null)
        _forceLogout()
        return Promise.reject(refreshError)
      } finally {
        _refreshing = false
      }
    }

    // ── Refresh token itself is invalid / expired → force logout ────────────
    // Only force logout if we were actively rotating a token (_refreshing),
    // NOT during initial session check from the router guard — that case is
    // handled gracefully by refreshSession()'s own catch block.
    if (status === 401 && isRefreshEndpoint && _refreshing) {
      _flushQueue(new Error('Refresh token expired or revoked'), null)
      _refreshing = false
      _forceLogout()
    }

    // ── Account locked ───────────────────────────────────────────────────────
    if (status === 403 && err.response?.data?.detail === 'Account is locked') {
      _forceLogout()
    }

    return Promise.reject(err)
  },
)

/**
 * Clear local auth state and redirect to login.
 * Lazy-imports the auth store to avoid circular dependency at module load.
 */
async function _forceLogout() {
  try {
    const { useAuthStore } = await import('@/stores/auth.js')
    await useAuthStore().resetLocalAuth()
  } catch {
    clearTokens()
  }
  const { default: router } = await import('@/router/index.js')
  if (router.currentRoute.value.path !== '/login') {
    await router.push('/login')
  }
}

export default apiClient