File size: 6,669 Bytes
f6c5cad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d543fc1
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
import React, { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';

const AuthContext = createContext(null);

const TOKEN_KEY    = 'wss_token';
const REFRESH_KEY  = 'wss_refresh_token';

// Parse the expiry time from a JWT without any library
function getTokenExpiry(token) {
  try {
    const payload = JSON.parse(atob(token.split('.')[1]));
    return payload.exp ? payload.exp * 1000 : null; // convert to ms
  } catch {
    return null;
  }
}

export const AuthProvider = ({ children }) => {
  const [user, setUser]               = useState(null);
  const [token, setToken]             = useState(localStorage.getItem(TOKEN_KEY));
  const [refreshToken, setRefreshToken] = useState(localStorage.getItem(REFRESH_KEY));
  const [loading, setLoading]         = useState(true);
  const proactiveRefreshRef           = useRef(null); // holds the setTimeout handle

  // ── Core logout β€” clear everything ───────────────────────────────────────
  const doLogout = useCallback(() => {
    if (proactiveRefreshRef.current) clearTimeout(proactiveRefreshRef.current);
    setToken(null);
    setRefreshToken(null);
    setUser(null);
    localStorage.removeItem(TOKEN_KEY);
    localStorage.removeItem(REFRESH_KEY);
  }, []);

  // ── Refresh access token (uses body refresh_token + HttpOnly cookie fallback)
  const refreshAccessToken = useCallback(async () => {
    const storedRefresh = localStorage.getItem(REFRESH_KEY);
    try {
      const res = await fetch('/api/auth/refresh', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include', // send HttpOnly cookie as fallback
        body: JSON.stringify({ refresh_token: storedRefresh || '' }),
      });
      if (res.ok) {
        const data = await res.json();
        setToken(data.access_token);
        localStorage.setItem(TOKEN_KEY, data.access_token);
        if (data.refresh_token) {
          setRefreshToken(data.refresh_token);
          localStorage.setItem(REFRESH_KEY, data.refresh_token);
        }
        return data.access_token;
      }
      // Refresh failed β€” do NOT auto-logout. Just return null and let callers decide.
      return null;
    } catch {
      return null;
    }
  }, []);

  // ── Schedule a proactive refresh 5 minutes before the access token expires ──
  const scheduleProactiveRefresh = useCallback((accessToken) => {
    if (proactiveRefreshRef.current) clearTimeout(proactiveRefreshRef.current);
    const expiry = getTokenExpiry(accessToken);
    if (!expiry) return;

    const now       = Date.now();
    const refreshAt = expiry - 5 * 60 * 1000; // 5 min before expiry
    const delay     = Math.max(refreshAt - now, 10_000); // min 10s

    proactiveRefreshRef.current = setTimeout(async () => {
      const newToken = await refreshAccessToken();
      if (newToken) {
        scheduleProactiveRefresh(newToken); // reschedule for the new token
      }
      // If refresh failed but user is still browsing, keep them logged in
      // until they actually hit a 401 (e.g. backend was briefly down)
    }, delay);
  }, [refreshAccessToken]);

  // ── Fetch user profile from backend ───────────────────────────────────────
  const fetchProfile = useCallback(async (activeToken, retryCount = 0) => {
    try {
      const res = await fetch('/api/auth/profile', {
        headers: { 'Authorization': `Bearer ${activeToken}` },
      });
      if (res.ok) {
        const data = await res.json();
        setUser(data.user);
        setLoading(false);
        scheduleProactiveRefresh(activeToken);
        return;
      }
      if (res.status === 401) {
        // Try to silently refresh before giving up
        const newToken = await refreshAccessToken();
        if (newToken) {
          const retryRes = await fetch('/api/auth/profile', {
            headers: { 'Authorization': `Bearer ${newToken}` },
          });
          if (retryRes.ok) {
            const data = await retryRes.json();
            setUser(data.user);
            setLoading(false);
            scheduleProactiveRefresh(newToken);
            return;
          }
        }
        // Only logout if both the access token AND refresh failed (401 Unauthorized)
        doLogout();
        setLoading(false);
        return;
      }
      
      // If backend is offline (502, 504), retry max 2 times then finish loading
      if (res.status >= 500 && retryCount < 2) {
        setTimeout(() => fetchProfile(activeToken, retryCount + 1), 1500);
        return;
      }
      setLoading(false);
    } catch {
      if (retryCount < 2) {
        setTimeout(() => fetchProfile(activeToken, retryCount + 1), 1500);
        return;
      }
      setLoading(false);
    }
  }, [refreshAccessToken, scheduleProactiveRefresh, doLogout]);

  // ── Sync token changes to localStorage & fetch profile ────────────────────
  useEffect(() => {
    if (token) {
      localStorage.setItem(TOKEN_KEY, token);
      fetchProfile(token);
    } else {
      localStorage.removeItem(TOKEN_KEY);
      setUser(null);
      setLoading(false);
    }
  }, [token]); // intentionally only re-run when token changes

  // ── Login β€” called after successful credentials check ─────────────────────
  const login = useCallback((newToken, newRefreshToken, userData) => {
    if (proactiveRefreshRef.current) clearTimeout(proactiveRefreshRef.current);
    setToken(newToken);
    setRefreshToken(newRefreshToken);
    setUser(userData);
    localStorage.setItem(TOKEN_KEY, newToken);
    if (newRefreshToken) localStorage.setItem(REFRESH_KEY, newRefreshToken);
    scheduleProactiveRefresh(newToken);
  }, [scheduleProactiveRefresh]);

  const logout = useCallback(() => {
    doLogout();
  }, [doLogout]);

  const reloadUser = useCallback(() => {
    if (token) {
      fetchProfile(token);
    }
  }, [token, fetchProfile]);

  // ── Cleanup proactive refresh on unmount ──────────────────────────────────
  useEffect(() => {
    return () => {
      if (proactiveRefreshRef.current) clearTimeout(proactiveRefreshRef.current);
    };
  }, []);

  return (
    <AuthContext.Provider value={{ user, token, refreshToken, loading, login, logout, refreshAccessToken, reloadUser }}>
      {children}
    </AuthContext.Provider>
  );
};

export const useAuth = () => useContext(AuthContext);