All files / src/utils/axios axiosInterceptor.js

95.45% Statements 63/66
89.58% Branches 43/48
83.33% Functions 10/12
95.23% Lines 60/63

Press n or j to go to the next uncovered block, b, p or k for the previous block.

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 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233        5x                                           5x     5x 5x 5x   5x 3x 3x         9x                                         8x       5x 5x 5x 5x         4x 4x     5x     5x                             5x   32x     32x       32x 32x           32x   8x 8x     3x 3x 2x     8x               32x               29x 29x 5x 5x 5x 4x           25x 20x 20x   20x             20x 6x 5x 5x 5x       20x 3x 3x                     20x 3x                   5x         3x         3x               2x 2x 2x                 25x               5x 25x    
import axios from "axios";
import { API_ENDPOINTS } from "@/api/endpoints";
import { getCsrfToken, setCsrfToken } from "@/authentication/session";
 
let isRedirecting = false;
 
/**
 * Hard ceiling on every request. axios ships with NO timeout by default, which
 * means a request that never answers never settles: no `.then`, no `.catch`,
 * no `finally`. Any `setLoading(false)` in a `finally` block simply never runs
 * and the UI shows its loading state forever.
 *
 * That is not hypothetical. The teacher dashboard did exactly this: the
 * submissions-statistics endpoint took 18.4 s (EI-1301, fixed in
 * eruditiontx-services-mvp #240 -- it was counting 1,620 soft-deleted classes),
 * and the page rendered loading skeletons the whole time with no error and no
 * way for the user to tell "slow" from "never".
 *
 * #240 removed that particular cause. This removes the CLASS of failure: with a
 * timeout, a hung request becomes a real rejection, the handler below turns it
 * into a visible dialog, and every `finally` in the app finally runs.
 *
 * 60 s is deliberately generous -- the worst legitimate call measured was that
 * 18.4 s one, and it is now fast. A specific call that genuinely needs longer
 * can still pass its own `timeout` in the request config, which overrides this.
 */
export const REQUEST_TIMEOUT_MS = 60_000;
 
// ADR-001 Phase 2 (BFF httpOnly-cookie auth).
const CSRF_COOKIE = "csrf_token";
const CSRF_HEADER = "X-CSRF-Token";
const UNSAFE_METHODS = new Set(["post", "put", "patch", "delete"]);
 
const readCookie = (name) => {
    const match = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
    return match ? decodeURIComponent(match[1]) : null;
};
 
// ADR-001 Phase 4 (refresh rotation). Auth routes are excluded from the 401
// REDIRECT below, so a failed probe on the login page can't bounce in a loop.
const isAuthEndpoint = (url) => String(url || "").includes("/auth/");
 
// The silent refresh-retry uses a NARROWER exclusion, and deliberately so.
//
// This used to reuse isAuthEndpoint, which swept in GET /v1/auth/me -- the probe
// AuthSessionProvider boots session state from. A 401 there got no refresh, so
// fetchSession() caught it, reported isAuthenticated: false, and ProtectedRoute
// sent the user to /login while a perfectly valid refresh_token sat unused in
// the cookie jar. Any full page load (reload, new tab, deep link, session
// restore) after the access token expired signed the user out for no reason.
//
// It was survivable at Auth0's default 86400s lifetime, where it needed a
// day-old tab. On 2026-09-21 the lifetime was cut to 900s (15 min) -- Auth0
// cannot revoke a JWT access token, so a short lifetime is the only mitigation
// there is -- and a routine page refresh started reaching it. Measured against
// QA: in-app navigation recovered correctly (three concurrent 401s collapsed
// into ONE refresh and replayed), while a reload landed on /login.
//
// Only /auth/refresh, /auth/login and /auth/logout can actually loop, and
// `_retried` already caps any request at a single attempt. /auth/me is safe to
// retry once, which is exactly what a session probe should do.
const canSilentlyRefresh = (url) => !/\/auth\/(refresh|login|logout)/.test(String(url || ""));
 
// Single-flight refresh: concurrent 401s share one POST /v1/auth/refresh, which
// rotates the httpOnly cookies. Resolves on success, rejects on a dead session.
let refreshPromise = null;
const refreshSession = () => {
    Eif (!refreshPromise) {
        refreshPromise = axios
            .post(API_ENDPOINTS.auth.refresh, {})
            .then((resp) => {
                // Refresh rotates the csrf cookie; capture the new value from the
                // body (the host-only cookie isn't readable cross-host).
                if (resp?.data?.csrf_token) setCsrfToken(resp.data.csrf_token);
                return resp;
            })
            .finally(() => {
                refreshPromise = null;
            });
    }
    return refreshPromise;
};
 
/**
 * Setup axios interceptors for BFF cookie auth:
 * 1. Send the httpOnly access_token cookie on every request (withCredentials).
 * 2. Double-submit CSRF: echo the readable csrf_token cookie as X-CSRF-Token
 *    on state-changing requests (the backend requires it for cookie auth).
 * 3. Handle API errors (401 redirect, 403/5xx dialogs).
 *
 * No `Authorization: Bearer` header is attached — the access token is never
 * read by the SPA anymore (closes the localStorage XSS-exfiltration finding).
 *
 * Called early in the app lifecycle (e.g., in main.jsx).
 */
export const setupAxiosInterceptors = () => {
    // The httpOnly access_token cookie must ride along on every request.
    axios.defaults.withCredentials = true;
 
    // Never let a request hang forever -- see REQUEST_TIMEOUT_MS.
    axios.defaults.timeout = REQUEST_TIMEOUT_MS;
 
    // One-time cleanup: purge any access token left in localStorage by a
    // pre-Phase-2 build so it can't be exfiltrated after the upgrade.
    try {
        localStorage.removeItem("access_token");
    } catch {
        /* ignore */
    }
 
    // Request interceptor — attach the CSRF header on unsafe methods.
    axios.interceptors.request.use(
        (config) => {
            const method = (config.method || "get").toLowerCase();
            if (UNSAFE_METHODS.has(method)) {
                // Prefer the in-memory token from the login/refresh/me body; fall
                // back to the cookie for same-host deployments where it's readable.
                const csrf = getCsrfToken() || readCookie(CSRF_COOKIE);
                if (csrf) {
                    config.headers[CSRF_HEADER] = csrf;
                }
            }
            return config;
        },
        (error) => {
            return Promise.reject(error);
        }
    );
 
    // Response interceptor — handle API errors
    axios.interceptors.response.use(
        (response) => response,
        async (error) => {
            // BFF (Phase 4): on a 401 from a normal API call, silently rotate the
            // session via /v1/auth/refresh once, then retry the original request.
            // Only the endpoints that could loop are excluded -- see
            // canSilentlyRefresh. /auth/me IS retried, so a page load after the
            // access token expires recovers instead of signing the user out.
            const original = error.config;
            if (error.response?.status === 401 && original && !original._retried && canSilentlyRefresh(original.url)) {
                original._retried = true;
                try {
                    await refreshSession();
                    return axios(original);
                } catch {
                    // Refresh failed → session is dead; fall through to redirect.
                }
            }
 
            if (error.response) {
                const status = error.response.status;
                const data = error.response.data;
 
                console.error("[Axios Interceptor] API Error:", {
                    status,
                    url: error.config?.url,
                    method: error.config?.method,
                    data,
                });
 
                if (status === 401 && !isAuthEndpoint(error.config?.url)) {
                    if (!isRedirecting) {
                        isRedirecting = true;
                        localStorage.removeItem("role");
                        window.location.href = "/";
                    }
                }
 
                if (status === 403) {
                    console.warn("Permission denied:", error.response?.config?.url);
                    window.dispatchEvent(
                        new CustomEvent("showPermissionDenied", {
                            detail: {
                                status: 403,
                                message:
                                    error.response?.data?.detail || "You don't have permission to access this resource",
                            },
                        })
                    );
                }
 
                if (status === 503 || status >= 500) {
                    window.dispatchEvent(
                        new CustomEvent("showMaintenanceDialog", {
                            detail: {
                                isMaintenance: status === 503,
                                message: error.response?.data?.detail || "The platform is temporarily unavailable.",
                                error: { status, data },
                            },
                        })
                    );
                }
            } else if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT") {
                // A TIMEOUT, not a lost connection. Reported separately because the
                // two need different responses: a network error means "check your
                // connection", a timeout means the server accepted the request and
                // never answered -- retrying immediately will usually hang again.
                console.error("[Axios Interceptor] Request Timeout:", {
                    url: error.config?.url,
                    method: error.config?.method,
                    timeoutMs: error.config?.timeout,
                });
                window.dispatchEvent(
                    new CustomEvent("showMaintenanceDialog", {
                        detail: {
                            error: { status: "TIMEOUT", message: error.message },
                            message: "The server took too long to respond. Please try again in a moment.",
                        },
                    })
                );
            } else if (error.request) {
                console.error("[Axios Interceptor] Network Error:", error.request);
                window.dispatchEvent(
                    new CustomEvent("showMaintenanceDialog", {
                        detail: { error: { status: "NETWORK_ERROR", message: error.message } },
                    })
                );
            } else E{
                console.error("[Axios Interceptor] Error:", error.message);
            }
 
            return Promise.reject(error);
        }
    );
};
 
/**
 * Reset redirecting flag (useful for testing or manual resets)
 */
export const resetRedirectingFlag = () => {
    isRedirecting = false;
};