fix csrf token hardset, remove cookie from localstorage
This commit is contained in:
+15
-38
@@ -16,9 +16,7 @@ export const isAxiosError = axios.isAxiosError;
|
||||
// Export api instance for direct use
|
||||
export { api as default };
|
||||
|
||||
// JWT Token Management
|
||||
const TOKEN_KEY = 'excalidash-access-token';
|
||||
const REFRESH_TOKEN_KEY = 'excalidash-refresh-token';
|
||||
// Auth state persisted in local storage should remain non-sensitive.
|
||||
const USER_KEY = 'excalidash-user';
|
||||
const AUTH_ENABLED_CACHE_KEY = "excalidash-auth-enabled";
|
||||
const AUTH_STATUS_TTL_MS = 5000;
|
||||
@@ -33,11 +31,6 @@ type RetriableRequestConfig = {
|
||||
|
||||
let authEnabledProbeCache: { value: boolean; fetchedAt: number } | null = null;
|
||||
|
||||
const getAuthToken = (): string | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
};
|
||||
|
||||
// CSRF Token Management
|
||||
let csrfToken: string | null = null;
|
||||
let csrfHeaderName: string = "x-csrf-token";
|
||||
@@ -96,25 +89,32 @@ export const authStatus = async (): Promise<AuthStatusResponse> => {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const authMe = async (accessToken: string): Promise<{ user: AuthUser }> => {
|
||||
export const authMe = async (): Promise<{ user: AuthUser }> => {
|
||||
const response = await axios.get<{ user: AuthUser }>(`${API_URL}/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
withCredentials: true,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const authRefresh = async (
|
||||
refreshToken: string
|
||||
refreshToken?: string
|
||||
): Promise<{ accessToken: string; refreshToken?: string }> => {
|
||||
const body =
|
||||
typeof refreshToken === "string" && refreshToken.trim().length > 0
|
||||
? { refreshToken }
|
||||
: {};
|
||||
const response = await axios.post<{ accessToken: string; refreshToken?: string }>(
|
||||
`${API_URL}/auth/refresh`,
|
||||
{ refreshToken },
|
||||
body,
|
||||
{ withCredentials: true }
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const authLogout = async (): Promise<void> => {
|
||||
await api.post("/auth/logout");
|
||||
};
|
||||
|
||||
export const authLogin = async (
|
||||
email: string,
|
||||
password: string
|
||||
@@ -152,8 +152,6 @@ export const authPasswordResetConfirm = async (
|
||||
};
|
||||
|
||||
const clearStoredAuth = () => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
};
|
||||
|
||||
@@ -205,23 +203,13 @@ let refreshPromise: Promise<string> | null = null;
|
||||
const refreshAccessToken = async (): Promise<string> => {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = (async () => {
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
if (!refreshToken) {
|
||||
throw new Error("Missing refresh token");
|
||||
}
|
||||
|
||||
const refreshResponse = await authRefresh(refreshToken);
|
||||
const refreshResponse = await authRefresh();
|
||||
|
||||
const nextAccessToken = String(refreshResponse.accessToken || "");
|
||||
if (!nextAccessToken) {
|
||||
throw new Error("Missing access token in refresh response");
|
||||
}
|
||||
|
||||
localStorage.setItem(TOKEN_KEY, nextAccessToken);
|
||||
if (refreshResponse.refreshToken) {
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshResponse.refreshToken);
|
||||
}
|
||||
|
||||
return nextAccessToken;
|
||||
})().finally(() => {
|
||||
refreshPromise = null;
|
||||
@@ -245,14 +233,6 @@ api.interceptors.request.use(
|
||||
|
||||
const isPublicAuthEndpoint = config.url && publicAuthEndpoints.some(endpoint => config.url?.startsWith(endpoint));
|
||||
|
||||
// Add JWT token to all requests except public auth endpoints
|
||||
if (!isPublicAuthEndpoint) {
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Only add CSRF token for state-changing methods (except public auth endpoints)
|
||||
const method = config.method?.toUpperCase();
|
||||
if (method && ["POST", "PUT", "DELETE", "PATCH"].includes(method) && !isPublicAuthEndpoint) {
|
||||
@@ -293,7 +273,6 @@ api.interceptors.response.use(
|
||||
const originalRequest = (error.config || {}) as RetriableRequestConfig;
|
||||
const url = String(originalRequest.url || "");
|
||||
const isAuthRoute = url.includes('/auth/');
|
||||
const hasRefreshToken = Boolean(localStorage.getItem(REFRESH_TOKEN_KEY));
|
||||
const authEnabled = !isAuthRoute ? await getAuthEnabledStatus() : true;
|
||||
|
||||
if (!isAuthRoute && authEnabled === false) {
|
||||
@@ -304,12 +283,10 @@ api.interceptors.response.use(
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (!isAuthRoute && hasRefreshToken && !originalRequest._retry) {
|
||||
if (!isAuthRoute && !originalRequest._retry) {
|
||||
try {
|
||||
originalRequest._retry = true;
|
||||
const nextAccessToken = await refreshAccessToken();
|
||||
originalRequest.headers = originalRequest.headers || {};
|
||||
originalRequest.headers.Authorization = `Bearer ${nextAccessToken}`;
|
||||
await refreshAccessToken();
|
||||
return api(originalRequest as any);
|
||||
} catch {
|
||||
clearStoredAuth();
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("AuthProvider", () => {
|
||||
},
|
||||
});
|
||||
|
||||
vi.spyOn(axios, "get").mockRejectedValueOnce(new Error("network down"));
|
||||
vi.spyOn(axios, "get").mockRejectedValue(new Error("network down"));
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
@@ -52,8 +52,6 @@ describe("AuthProvider", () => {
|
||||
|
||||
it("clears stored auth state when backend reports auth disabled", async () => {
|
||||
const storage = new Map<string, string>([
|
||||
["excalidash-access-token", "token"],
|
||||
["excalidash-refresh-token", "refresh"],
|
||||
["excalidash-user", JSON.stringify({ id: "u1" })],
|
||||
]);
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
@@ -83,16 +81,12 @@ describe("AuthProvider", () => {
|
||||
expect(screen.getByTestId("loading").textContent).toBe("false");
|
||||
});
|
||||
expect(screen.getByTestId("auth-enabled").textContent).toBe("false");
|
||||
expect(storage.get("excalidash-access-token")).toBeUndefined();
|
||||
expect(storage.get("excalidash-refresh-token")).toBeUndefined();
|
||||
expect(storage.get("excalidash-user")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses cached auth-disabled mode when /auth/status is temporarily unavailable", async () => {
|
||||
const storage = new Map<string, string>([
|
||||
["excalidash-auth-enabled", "false"],
|
||||
["excalidash-access-token", "token"],
|
||||
["excalidash-refresh-token", "refresh"],
|
||||
["excalidash-user", JSON.stringify({ id: "u1" })],
|
||||
]);
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
@@ -108,7 +102,7 @@ describe("AuthProvider", () => {
|
||||
},
|
||||
});
|
||||
|
||||
vi.spyOn(axios, "get").mockRejectedValueOnce(new Error("network down"));
|
||||
vi.spyOn(axios, "get").mockRejectedValue(new Error("network down"));
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
@@ -122,8 +116,6 @@ describe("AuthProvider", () => {
|
||||
expect(screen.getByTestId("loading").textContent).toBe("false");
|
||||
});
|
||||
expect(screen.getByTestId("auth-enabled").textContent).toBe("false");
|
||||
expect(storage.get("excalidash-access-token")).toBeUndefined();
|
||||
expect(storage.get("excalidash-refresh-token")).toBeUndefined();
|
||||
expect(storage.get("excalidash-user")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
authStatus,
|
||||
authMe,
|
||||
authRefresh,
|
||||
authLogout,
|
||||
authLogin,
|
||||
authRegister,
|
||||
isAxiosError,
|
||||
@@ -32,8 +33,6 @@ interface AuthContextType {
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
const TOKEN_KEY = 'excalidash-access-token';
|
||||
const REFRESH_TOKEN_KEY = 'excalidash-refresh-token';
|
||||
const USER_KEY = 'excalidash-user';
|
||||
const AUTH_ENABLED_CACHE_KEY = "excalidash-auth-enabled";
|
||||
|
||||
@@ -60,8 +59,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
setBootstrapRequired(Boolean(statusResponse?.bootstrapRequired));
|
||||
|
||||
if (!enabled) {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
setUser(null);
|
||||
return;
|
||||
@@ -71,8 +68,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
if (cachedAuthEnabled === "false") {
|
||||
setAuthEnabled(false);
|
||||
setBootstrapRequired(false);
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
setUser(null);
|
||||
return;
|
||||
@@ -82,44 +77,28 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
}
|
||||
|
||||
const storedUser = localStorage.getItem(USER_KEY);
|
||||
const storedToken = localStorage.getItem(TOKEN_KEY);
|
||||
|
||||
if (storedUser && storedToken) {
|
||||
if (storedUser) {
|
||||
const userData = JSON.parse(storedUser);
|
||||
setUser(userData);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await authMe();
|
||||
setUser(response.user);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(response.user));
|
||||
} catch {
|
||||
try {
|
||||
const response = await authMe(storedToken);
|
||||
setUser(response.user);
|
||||
await authRefresh();
|
||||
const userResponse = await authMe();
|
||||
setUser(userResponse.user);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(userResponse.user));
|
||||
} catch {
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const refreshResponse = await authRefresh(refreshToken);
|
||||
localStorage.setItem(TOKEN_KEY, refreshResponse.accessToken);
|
||||
if (refreshResponse.refreshToken) {
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshResponse.refreshToken);
|
||||
}
|
||||
const userResponse = await authMe(refreshResponse.accessToken);
|
||||
setUser(userResponse.user);
|
||||
} catch {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
setUser(null);
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
setUser(null);
|
||||
}
|
||||
localStorage.removeItem(USER_KEY);
|
||||
setUser(null);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load user:', error);
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
setUser(null);
|
||||
} finally {
|
||||
@@ -137,10 +116,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
}
|
||||
const response = await authLogin(email, password);
|
||||
|
||||
const { user: userData, accessToken, refreshToken } = response;
|
||||
const { user: userData } = response;
|
||||
|
||||
localStorage.setItem(TOKEN_KEY, accessToken);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(userData));
|
||||
|
||||
setUser(userData);
|
||||
@@ -166,10 +143,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
}
|
||||
const response = await authRegister(email, password, name);
|
||||
|
||||
const { user: userData, accessToken, refreshToken } = response;
|
||||
const { user: userData } = response;
|
||||
|
||||
localStorage.setItem(TOKEN_KEY, accessToken);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(userData));
|
||||
|
||||
setUser(userData);
|
||||
@@ -189,8 +164,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
void authLogout().catch(() => undefined);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
setUser(null);
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -7,11 +7,9 @@ import * as api from '../api';
|
||||
import type { Collection } from '../types';
|
||||
import { Shield, UserPlus, RefreshCw, UserCog, LogIn, XCircle, Settings as SettingsIcon, KeyRound } from 'lucide-react';
|
||||
import {
|
||||
ACCESS_TOKEN_KEY,
|
||||
IMPERSONATION_KEY,
|
||||
type ImpersonationState,
|
||||
readImpersonationState,
|
||||
REFRESH_TOKEN_KEY,
|
||||
stopImpersonation as restoreImpersonation,
|
||||
USER_KEY,
|
||||
} from '../utils/impersonation';
|
||||
@@ -290,11 +288,9 @@ export const Admin: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalAccessToken = localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
const originalRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
const originalUser = localStorage.getItem(USER_KEY);
|
||||
if (!originalAccessToken || !originalRefreshToken || !originalUser) {
|
||||
setError('Missing current session tokens.');
|
||||
if (!originalUser) {
|
||||
setError('Missing current session user state.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -307,8 +303,6 @@ export const Admin: React.FC = () => {
|
||||
|
||||
const state: ImpersonationState = {
|
||||
original: {
|
||||
accessToken: originalAccessToken,
|
||||
refreshToken: originalRefreshToken,
|
||||
user: JSON.parse(originalUser),
|
||||
},
|
||||
impersonator: {
|
||||
@@ -325,8 +319,6 @@ export const Admin: React.FC = () => {
|
||||
};
|
||||
|
||||
localStorage.setItem(IMPERSONATION_KEY, JSON.stringify(state));
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, response.data.accessToken);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, response.data.refreshToken);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(response.data.user));
|
||||
|
||||
window.location.href = '/';
|
||||
@@ -339,9 +331,26 @@ export const Admin: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const stopImpersonation = () => {
|
||||
if (!restoreImpersonation()) return;
|
||||
window.location.href = '/admin';
|
||||
const stopImpersonation = async () => {
|
||||
if (!readImpersonationState()) return;
|
||||
|
||||
try {
|
||||
const response = await api.api.post<{
|
||||
user?: { id: string; email: string; name: string };
|
||||
}>('/auth/stop-impersonation');
|
||||
|
||||
restoreImpersonation();
|
||||
if (response.data?.user) {
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(response.data.user));
|
||||
}
|
||||
window.location.href = '/admin';
|
||||
} catch (err: unknown) {
|
||||
let message = 'Failed to stop impersonation';
|
||||
if (api.isAxiosError(err)) {
|
||||
message = err.response?.data?.message || err.response?.data?.error || message;
|
||||
}
|
||||
setError(message);
|
||||
}
|
||||
};
|
||||
|
||||
if (authEnabled === null) {
|
||||
|
||||
@@ -962,7 +962,7 @@ export const Dashboard: React.FC = () => {
|
||||
) : (
|
||||
<div
|
||||
className={clsx("grid gap-3 sm:gap-4 pb-16 sm:pb-24 transition-all duration-300", isDraggingFile && "opacity-20 blur-sm")}
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))' }}
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))' }}
|
||||
>
|
||||
{sortedDrawings.length === 0 ? (
|
||||
<div className="col-span-full flex flex-col items-center justify-center py-16 sm:py-32 text-slate-400 dark:text-neutral-500 border-2 border-dashed border-slate-200 dark:border-neutral-700 rounded-3xl bg-slate-50/50 dark:bg-neutral-800/50">
|
||||
|
||||
@@ -257,11 +257,10 @@ export const Editor: React.FC = () => {
|
||||
? window.location.origin
|
||||
: (import.meta.env.VITE_API_URL || 'http://localhost:8000');
|
||||
|
||||
const authToken = localStorage.getItem('excalidash-access-token');
|
||||
const socket = io(socketUrl, {
|
||||
path: '/socket.io',
|
||||
transports: ['websocket', 'polling'],
|
||||
auth: authToken ? { token: authToken } : {},
|
||||
withCredentials: true,
|
||||
});
|
||||
socketRef.current = socket;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate, Link, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { Logo } from '../components/Logo';
|
||||
import * as api from '../api';
|
||||
import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, USER_KEY } from '../utils/impersonation';
|
||||
import { USER_KEY } from '../utils/impersonation';
|
||||
|
||||
export const Login: React.FC = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
@@ -81,8 +81,6 @@ export const Login: React.FC = () => {
|
||||
refreshToken: string;
|
||||
}>('/auth/must-reset-password', { newPassword });
|
||||
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, response.data.accessToken);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, response.data.refreshToken);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(response.data.user));
|
||||
|
||||
window.location.href = '/';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useAuth } from '../context/AuthContext';
|
||||
import * as api from '../api';
|
||||
import type { Collection } from '../types';
|
||||
import { User, Lock, Save, X, Shield } from 'lucide-react';
|
||||
import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, USER_KEY } from '../utils/impersonation';
|
||||
import { USER_KEY } from '../utils/impersonation';
|
||||
|
||||
export const Profile: React.FC = () => {
|
||||
const { user: authUser, logout, authEnabled } = useAuth();
|
||||
@@ -234,8 +234,6 @@ export const Profile: React.FC = () => {
|
||||
currentPassword: emailCurrentPassword,
|
||||
});
|
||||
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, response.data.accessToken);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, response.data.refreshToken);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(response.data.user));
|
||||
|
||||
setSuccess('Email updated successfully');
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
export const ACCESS_TOKEN_KEY = 'excalidash-access-token';
|
||||
export const REFRESH_TOKEN_KEY = 'excalidash-refresh-token';
|
||||
export const USER_KEY = 'excalidash-user';
|
||||
export const IMPERSONATION_KEY = 'excalidash-impersonation';
|
||||
|
||||
export type ImpersonationState = {
|
||||
original: {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
user: unknown;
|
||||
};
|
||||
impersonator: {
|
||||
@@ -28,7 +24,7 @@ export const readImpersonationState = (): ImpersonationState | null => {
|
||||
const raw = localStorage.getItem(IMPERSONATION_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as ImpersonationState;
|
||||
if (!parsed?.original?.accessToken || !parsed?.original?.refreshToken) return null;
|
||||
if (!parsed?.original?.user) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -38,10 +34,7 @@ export const readImpersonationState = (): ImpersonationState | null => {
|
||||
export const stopImpersonation = (): boolean => {
|
||||
const state = readImpersonationState();
|
||||
if (!state) return false;
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, state.original.accessToken);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, state.original.refreshToken);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(state.original.user));
|
||||
localStorage.removeItem(IMPERSONATION_KEY);
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user