feat(security): HttpOnly cookies + CSP headers + CSRF double-submit protection

- Migrate auth tokens from localStorage to HttpOnly SameSite=Lax cookies
- Add cookie_auth.rs: helpers for setting/clearing auth + CSRF cookies
- Update auth middleware: 3-method auth (Bearer → Basic → Cookie)
- Add 5 security headers: CSP, X-Content-Type-Options, X-Frame-Options,
  Referrer-Policy, Permissions-Policy
- Implement CSRF double-submit cookie pattern (csrf.rs middleware)
- Set CSRF cookie on login/refresh/oidc-exchange, clear on logout
- CookieAuthenticated marker skips CSRF for Bearer/Basic clients
- Frontend: strip all localStorage token refs from 14 JS files
- Frontend: csrf.js utility + all 52 mutating fetch/XHR calls protected
- 121 tests passing, 0 warnings
This commit is contained in:
Dionisio
2026-03-03 01:10:50 +01:00
parent 7b2a8577a9
commit d2c08d31ba
27 changed files with 579 additions and 455 deletions
+1
View File
@@ -6,6 +6,7 @@
<title>OxiCloud — Admin Panel</title>
<script>if(localStorage.getItem('oxicloud_theme')==='dark')document.documentElement.setAttribute('data-theme','dark');</script>
<script src="/js/core/icons.js" defer></script>
<script src="/js/core/csrf.js" defer></script>
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/views/admin.css">
</head>
+7 -27
View File
@@ -137,6 +137,7 @@
<div id="status-error" class="status error" id="status-error-msg"></div>
</div>
<script src="/js/core/csrf.js"></script>
<script>
const API_BASE = window.location.origin;
const codeInput = document.getElementById('user-code');
@@ -175,14 +176,13 @@
async function lookupCode(code) {
try {
const token = getAuthToken();
if (!token) {
const resp = await fetch(`${API_BASE}/api/auth/device/verify?code=${encodeURIComponent(code)}`, {
credentials: 'same-origin'
});
if (resp.status === 401) {
showError('You must be logged in to authorize a device. Please log in first.');
return;
}
const resp = await fetch(`${API_BASE}/api/auth/device/verify?code=${encodeURIComponent(code)}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!resp.ok) throw new Error('Lookup failed');
const data = await resp.json();
@@ -210,13 +210,10 @@
btnDeny.disabled = true;
try {
const token = getAuthToken();
const resp = await fetch(`${API_BASE}/api/auth/device/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
body: JSON.stringify({ user_code: currentCode, action: action })
});
@@ -242,23 +239,6 @@
errorText.textContent = msg;
errorText.style.display = 'block';
}
function getAuthToken() {
// Try localStorage (OxiCloud frontend stores tokens there)
try {
const stored = localStorage.getItem('auth_token')
|| localStorage.getItem('access_token')
|| localStorage.getItem('oxicloud_token');
if (stored) return stored;
// Try parsing a JSON auth object
const authData = localStorage.getItem('auth');
if (authData) {
const parsed = JSON.parse(authData);
return parsed.access_token || parsed.token;
}
} catch {}
return null;
}
</script>
</body>
</html>
+1
View File
@@ -17,6 +17,7 @@
<!-- Scripts (defer: download in parallel, execute in order, after HTML parsed) -->
<script defer src="/js/core/i18n.js"></script>
<script defer src="/js/core/csrf.js"></script>
<script defer src="/js/core/languageSelector.js"></script>
<script defer src="/js/core/notifications.js"></script>
<script defer src="/js/core/modal.js"></script>
+27 -77
View File
@@ -3,25 +3,13 @@
*/
async function refreshUserData() {
const TOKEN_KEY = 'oxicloud_token';
const USER_DATA_KEY = 'oxicloud_user';
const token = localStorage.getItem(TOKEN_KEY);
console.log('refreshUserData called, token:', token ? token.substring(0, 20) + '...' : 'null');
if (!token) {
console.log('No valid token, skipping user data refresh');
return null;
}
try {
console.log('Fetching /api/auth/me...');
console.log('Fetching /api/auth/me (cookie-based)...');
const response = await fetch('/api/auth/me', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
credentials: 'same-origin'
});
console.log('/api/auth/me response status:', response.status);
@@ -47,9 +35,6 @@ async function refreshUserData() {
async function checkAuthentication() {
try {
const TOKEN_KEY = 'oxicloud_token';
const REFRESH_TOKEN_KEY = 'oxicloud_refresh_token';
const TOKEN_EXPIRY_KEY = 'oxicloud_token_expiry';
const USER_DATA_KEY = 'oxicloud_user';
const urlParams = new URLSearchParams(window.location.search);
@@ -60,8 +45,9 @@ async function checkAuthentication() {
try {
const exchangeResponse = await fetch('/api/auth/oidc/exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: oidcCode })
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
body: JSON.stringify({ code: oidcCode }),
credentials: 'same-origin'
});
if (!exchangeResponse.ok) {
@@ -74,43 +60,14 @@ async function checkAuthentication() {
const data = await exchangeResponse.json();
console.log('OIDC token exchange successful');
const token = data.access_token || data.token;
const refreshToken = data.refresh_token || data.refreshToken;
if (token) {
localStorage.setItem(TOKEN_KEY, token);
if (refreshToken) localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
let parsedExpiry = false;
const tokenParts = token.split('.');
if (tokenParts.length === 3) {
try {
const payload = JSON.parse(atob(tokenParts[1]));
if (payload.exp) {
const expiryDate = new Date(payload.exp * 1000);
if (!isNaN(expiryDate.getTime())) {
localStorage.setItem(TOKEN_EXPIRY_KEY, expiryDate.toISOString());
parsedExpiry = true;
}
}
} catch (e) {
console.error('Error parsing JWT:', e);
}
}
if (!parsedExpiry) {
const expiry = new Date();
expiry.setDate(expiry.getDate() + 30);
localStorage.setItem(TOKEN_EXPIRY_KEY, expiry.toISOString());
}
if (data.user) {
localStorage.setItem(USER_DATA_KEY, JSON.stringify(data.user));
}
window.history.replaceState({}, document.title, '/');
window.location.reload();
return;
// Tokens are now in HttpOnly cookies set by the server.
if (data.user) {
localStorage.setItem(USER_DATA_KEY, JSON.stringify(data.user));
}
window.history.replaceState({}, document.title, '/');
window.location.reload();
return;
} catch (err) {
console.error('OIDC exchange error:', err);
window.location.href = '/login?source=oidc_error';
@@ -118,18 +75,12 @@ async function checkAuthentication() {
}
}
const token = localStorage.getItem(TOKEN_KEY);
if (!token) {
console.log('No token found, redirecting to login');
window.location.href = '/login?source=app';
return;
}
console.log('Token found, proceeding with app initialization');
// Check session validity by calling /api/auth/me (cookie auto-sent)
console.log('Checking session via /api/auth/me...');
const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}');
if (userData.username) {
// We have cached user data — render immediately, refresh in background
const userInitials = userData.username.substring(0, 2).toUpperCase();
document.querySelectorAll('.user-avatar, .user-menu-avatar').forEach(el => {
el.textContent = userInitials;
@@ -144,6 +95,15 @@ async function checkAuthentication() {
refreshUserData().then(freshData => {
if (freshData) {
console.log('Storage usage updated from server');
} else {
// Session expired — try silent refresh first
console.warn('Session may have expired, trying refresh...');
fetch('/api/auth/refresh', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() }, body: '{}' })
.then(r => r.ok ? refreshUserData() : Promise.reject(new Error('refresh failed')))
.catch(() => {
localStorage.removeItem(USER_DATA_KEY);
window.location.href = '/login?source=session_expired';
});
}
}).catch(err => {
console.warn('Could not refresh user data:', err);
@@ -151,7 +111,8 @@ async function checkAuthentication() {
resolveHomeFolder().then(() => window.loadFiles());
} else {
console.log('No user data, attempting to fetch from server');
// No cached user data — must verify session from server
console.log('No cached user data, fetching from server');
try {
const freshData = await refreshUserData();
if (freshData && freshData.username) {
@@ -161,26 +122,17 @@ async function checkAuthentication() {
resolveHomeFolder().then(() => window.loadFiles());
} else {
console.warn('Could not retrieve user data, redirecting to login');
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(TOKEN_EXPIRY_KEY);
localStorage.removeItem(USER_DATA_KEY);
window.location.href = '/login?source=invalid_session';
}
} catch (err) {
console.error('Failed to fetch user data:', err);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(TOKEN_EXPIRY_KEY);
localStorage.removeItem(USER_DATA_KEY);
window.location.href = '/login?source=session_error';
}
}
} catch (error) {
console.error('Error during authentication check:', error);
localStorage.removeItem('oxicloud_token');
localStorage.removeItem('oxicloud_refresh_token');
localStorage.removeItem('oxicloud_token_expiry');
localStorage.removeItem('oxicloud_user');
window.location.href = '/login?source=auth_error';
}
@@ -191,9 +143,7 @@ async function resolveHomeFolder() {
if (app.userHomeFolderId) return;
try {
const token = localStorage.getItem('oxicloud_token');
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
const response = await fetch('/api/folders', { headers });
const response = await fetch('/api/folders', { credentials: 'same-origin' });
if (!response.ok) {
console.warn(`Could not fetch home folder: ${response.status}`);
return;
+1 -4
View File
@@ -48,16 +48,13 @@ async function loadFiles(options = {}) {
console.log(`Loading subfolder content: ${app.currentPath}`);
}
const token = localStorage.getItem('oxicloud_token');
const headers = {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache'
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const requestOptions = {
headers,
credentials: 'same-origin',
cache: 'no-store'
};
+8 -10
View File
@@ -224,18 +224,16 @@ function showUserProfileModal() {
}
function logout() {
const TOKEN_KEY = 'oxicloud_token';
const REFRESH_TOKEN_KEY = 'oxicloud_refresh_token';
const TOKEN_EXPIRY_KEY = 'oxicloud_token_expiry';
const USER_DATA_KEY = 'oxicloud_user';
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(TOKEN_EXPIRY_KEY);
localStorage.removeItem(USER_DATA_KEY);
sessionStorage.removeItem('redirect_count');
window.location.href = '/login';
// Tell the server to clear HttpOnly cookies
fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin', headers: getCsrfHeaders() })
.catch(() => {}) // Best-effort
.finally(() => {
localStorage.removeItem(USER_DATA_KEY);
sessionStorage.removeItem('redirect_count');
window.location.href = '/login';
});
}
window.setupUserMenu = setupUserMenu;
+28
View File
@@ -0,0 +1,28 @@
/**
* CSRF double-submit cookie utility.
*
* Reads the `oxicloud_csrf` cookie (which is NOT HttpOnly) and provides
* its value as the `X-CSRF-Token` header on mutating requests.
*
* Usage:
* // In any fetch call that changes state:
* fetch(url, { method: 'POST', headers: { ...getCsrfHeaders(), 'Content-Type': 'application/json' } })
*
* The server-side `csrf_middleware` validates that the header value matches
* the cookie for every POST/PUT/DELETE/PATCH request authenticated via
* HttpOnly cookies.
*/
// eslint-disable-next-line no-unused-vars
function getCsrfToken() {
const match = document.cookie
.split('; ')
.find(row => row.startsWith('oxicloud_csrf='));
return match ? match.split('=')[1] : '';
}
// eslint-disable-next-line no-unused-vars
function getCsrfHeaders() {
const token = getCsrfToken();
return token ? { 'X-CSRF-Token': token } : {};
}
+66 -203
View File
@@ -10,10 +10,8 @@ const REGISTER_ENDPOINT = `${API_URL}/register`;
const ME_ENDPOINT = `${API_URL}/me`;
const REFRESH_ENDPOINT = `${API_URL}/refresh`;
// Storage keys
const TOKEN_KEY = 'oxicloud_token';
const REFRESH_TOKEN_KEY = 'oxicloud_refresh_token';
const TOKEN_EXPIRY_KEY = 'oxicloud_token_expiry';
// Storage keys — tokens are now in HttpOnly cookies (set by server).
// Only non-sensitive display data is kept in localStorage.
const USER_DATA_KEY = 'oxicloud_user';
const LOCALE_KEY = 'oxicloud-locale';
const FIRST_RUN_KEY = 'oxicloud_first_run_completed';
@@ -491,24 +489,18 @@ let authInitialized = false;
// Case 1: High refresh attempts
if (refreshAttempts > 3) {
console.error('EMERGENCY: Detected severe token refresh loop. Cleaning all auth data.');
localStorage.clear(); // Full localStorage clear to ensure we break the loop
localStorage.removeItem(USER_DATA_KEY);
sessionStorage.clear();
localStorage.setItem('emergency_clean', 'true');
// Store timestamp of the cleanup for stability
localStorage.setItem('last_emergency_clean', Date.now().toString());
// No alert to avoid overwhelming the user if this happens multiple times
}
// Case 2: We were redirected from app due to auth issues
if (redirectSource === 'app') {
console.log('Detected redirect from app, ensuring clean auth state');
// Clear only auth-related data to ensure a clean login
localStorage.removeItem('oxicloud_token');
localStorage.removeItem('oxicloud_refresh_token');
localStorage.removeItem('oxicloud_token_expiry');
localStorage.removeItem(USER_DATA_KEY);
// Reset counters
sessionStorage.removeItem('redirect_count');
localStorage.setItem('refresh_attempts', '0');
@@ -520,9 +512,7 @@ let authInitialized = false;
if (lastCleanup > 0 && timeSinceCleanup < 10000) { // Less than 10 seconds
console.warn('Multiple auth problems in short time, clearing auth data');
localStorage.removeItem('oxicloud_token');
localStorage.removeItem('oxicloud_refresh_token');
localStorage.removeItem('oxicloud_token_expiry');
localStorage.removeItem(USER_DATA_KEY);
}
})();
@@ -549,7 +539,7 @@ document.addEventListener('DOMContentLoaded', () => {
try {
const resp = await fetch('/api/auth/oidc/exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
body: JSON.stringify({ code: oidcCode })
});
@@ -559,35 +549,15 @@ document.addEventListener('DOMContentLoaded', () => {
}
const data = await resp.json();
const token = data.access_token || data.token;
const refreshToken = data.refresh_token || data.refreshToken;
if (token) {
localStorage.setItem(TOKEN_KEY, token);
if (refreshToken) localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
// Parse JWT expiry
const tokenParts = token.split('.');
if (tokenParts.length === 3) {
try {
const payload = JSON.parse(atob(tokenParts[1]));
if (payload.exp) {
const expiryDate = new Date(payload.exp * 1000);
if (!isNaN(expiryDate.getTime())) {
localStorage.setItem(TOKEN_EXPIRY_KEY, expiryDate.toISOString());
}
}
} catch (e) { /* ignore parse errors */ }
}
if (data.user) {
localStorage.setItem(USER_DATA_KEY, JSON.stringify(data.user));
}
// Redirect to main app
window.location.href = '/';
return;
// Tokens are now set as HttpOnly cookies by the server.
// Just store user display data and redirect.
if (data.user) {
localStorage.setItem(USER_DATA_KEY, JSON.stringify(data.user));
}
// Redirect to main app
window.location.href = '/';
return;
} catch (err) {
console.error('OIDC exchange error:', err);
}
@@ -617,55 +587,30 @@ document.addEventListener('DOMContentLoaded', () => {
(async () => {
try {
// First check if the token is valid
const token = localStorage.getItem(TOKEN_KEY);
const tokenExpiry = localStorage.getItem(TOKEN_EXPIRY_KEY);
if (!token) {
console.log('No token found, user needs to login');
// Clear any stale data
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(TOKEN_EXPIRY_KEY);
localStorage.removeItem(USER_DATA_KEY);
return; // Stay on login page
}
// Check if token expiry is valid and not expired
// Check if we already have a valid session (cookie-based).
// The HttpOnly cookie is sent automatically — just probe /api/auth/me.
try {
const expiryDate = new Date(tokenExpiry);
if (!isNaN(expiryDate.getTime()) && expiryDate > new Date()) {
console.log(`Token valid until ${expiryDate.toLocaleString()}`);
// Token still valid, redirect to main app
const meResp = await fetch(ME_ENDPOINT, { method: 'GET', credentials: 'same-origin' });
if (meResp.ok) {
console.log('Session still valid, redirecting to app');
const userData = await meResp.json();
localStorage.setItem(USER_DATA_KEY, JSON.stringify(userData));
redirectToMainApp();
return;
} else {
console.log('Token expired or invalid date, attempting refresh');
}
} catch (dateError) {
console.error('Error parsing token expiry date:', dateError);
// Continue to refresh attempt
}
// Token expired, try to refresh
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
if (refreshToken) {
try {
console.log('Attempting to refresh expired token');
await refreshAuthToken(refreshToken);
// 401 / other → try a silent refresh
console.log('Session check returned', meResp.status, '— trying refresh');
const refreshOk = await refreshAuthToken();
if (refreshOk) {
console.log('Token refresh successful, redirecting to app');
redirectToMainApp();
} catch (error) {
// Refresh failed, continue with login page
console.log('Token refresh failed, user needs to login again:', error.message);
// Clear any stale auth data
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(TOKEN_EXPIRY_KEY);
localStorage.removeItem(USER_DATA_KEY);
return;
}
} else {
console.log('No refresh token found, user needs to login');
} catch (err) {
console.log('Session probe failed, showing login page:', err.message);
}
// No valid session — stay on login page
localStorage.removeItem(USER_DATA_KEY);
// Check if admin account exists (customize this as needed)
const isFirstRun = await checkFirstRun();
@@ -694,61 +639,16 @@ if (isLoginPage && loginForm) {
try {
const data = await login(username, password);
// Store auth data
console.log("Login response:", data); // Log the response for debugging
// Use the correct field names from our API response
const token = data.access_token || data.token;
const refreshToken = data.refresh_token || data.refreshToken;
if (!token) {
throw new Error('Server did not return an access token');
}
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
// Extract expiration date from the JWT token
let parsedExpiry = false;
const tokenParts = token.split('.');
if (tokenParts.length === 3) {
try {
const payload = JSON.parse(atob(tokenParts[1]));
if (payload.exp) {
// payload.exp is in seconds since epoch
const expiryDate = new Date(payload.exp * 1000);
// Verify the date is valid
if (!isNaN(expiryDate.getTime())) {
localStorage.setItem(TOKEN_EXPIRY_KEY, expiryDate.toISOString());
parsedExpiry = true;
console.log(`Token expires on: ${expiryDate.toLocaleString()}`);
} else {
console.warn('Invalid expiry date in token:', payload.exp);
}
}
} catch (e) {
console.error('Error parsing JWT token:', e);
}
}
// If we couldn't parse the expiry, set a default (30 days)
if (!parsedExpiry) {
console.log('Setting default token expiry (30 days)');
const expiryTime = new Date();
expiryTime.setDate(expiryTime.getDate() + 30); // 30 days instead of 1 hour
localStorage.setItem(TOKEN_EXPIRY_KEY, expiryTime.toISOString());
}
// Tokens are now set as HttpOnly cookies by the server.
// Just store non-sensitive user data for display.
console.log('Login succeeded');
// Reset redirect counter on successful login
sessionStorage.removeItem('redirect_count');
localStorage.setItem('refresh_attempts', '0');
// Fetch and store user data from the response
if (data.user) {
console.log("Storing user data from server");
localStorage.setItem(USER_DATA_KEY, JSON.stringify(data.user));
} else {
console.warn("Server did not return user data — will fetch from /me endpoint after redirect");
}
// Redirect to main app
@@ -871,7 +771,8 @@ async function login(username, password) {
const response = await fetch(LOGIN_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
...getCsrfHeaders()
},
body: JSON.stringify({ username, password }),
signal: controller.signal
@@ -917,7 +818,8 @@ async function register(username, email, password, role = 'user') {
const response = await fetch(REGISTER_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
...getCsrfHeaders()
},
body: JSON.stringify({ username, email, password, role })
});
@@ -951,15 +853,13 @@ async function register(username, email, password, role = 'user') {
}
/**
* Fetch current user data
* Fetch current user data — relies on HttpOnly cookie (auto-sent).
*/
async function fetchUserData(token) {
async function fetchUserData() {
try {
const response = await fetch(ME_ENDPOINT, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
}
credentials: 'same-origin'
});
if (!response.ok) {
@@ -974,64 +874,47 @@ async function fetchUserData(token) {
}
/**
* Refresh authentication token - MAJOR CHANGE: Reduced functionality to break token loop
* Refresh authentication token via the server's refresh endpoint.
* The refresh-token cookie is sent automatically (HttpOnly, Path=/api/auth).
* Returns true on success, false on failure.
*/
async function refreshAuthToken(refreshToken) {
async function refreshAuthToken() {
try {
// Check if we're in a refresh loop
// Loop-breaker
const refreshAttempts = parseInt(localStorage.getItem('refresh_attempts') || '0');
localStorage.setItem('refresh_attempts', (refreshAttempts + 1).toString());
if (refreshAttempts > 3) {
console.error('Refresh token loop detected, clearing all auth data');
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(TOKEN_EXPIRY_KEY);
console.error('Refresh token loop detected, giving up');
localStorage.removeItem(USER_DATA_KEY);
localStorage.removeItem('refresh_attempts');
sessionStorage.removeItem('redirect_count');
throw new Error('Too many refresh attempts, forcing login');
return false;
}
if (!refreshToken) {
throw new Error('No refresh token available');
}
console.log('Attempting to refresh token (cookie-based)');
console.log("Attempting to refresh token");
// Timeout for safety
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(REFRESH_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refresh_token: refreshToken }),
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
body: '{}',
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
console.warn(`Refresh token failed with status: ${response.status}`);
throw new Error(`Token refresh failed: ${response.status}`);
console.warn('Refresh failed with status:', response.status);
return false;
}
const data = await response.json();
console.log("Refresh token response:", data);
// Default expiry if we can't extract from token (30 days)
const expiryTime = new Date();
expiryTime.setDate(expiryTime.getDate() + 30);
// Update stored tokens minimally to avoid parsing issues
localStorage.setItem(TOKEN_KEY, data.access_token || data.token);
localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token || data.refreshToken || refreshToken);
localStorage.setItem(TOKEN_EXPIRY_KEY, expiryTime.toISOString());
// Store user data if provided
// Store user display data if provided
if (data.user) {
localStorage.setItem(USER_DATA_KEY, JSON.stringify(data.user));
}
@@ -1040,17 +923,13 @@ async function refreshAuthToken(refreshToken) {
localStorage.setItem('refresh_attempts', '0');
sessionStorage.removeItem('redirect_count');
return data;
return true;
} catch (error) {
console.error('Token refresh error:', error);
// Clear stored auth data on refresh failure
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(TOKEN_EXPIRY_KEY);
localStorage.removeItem(USER_DATA_KEY);
localStorage.removeItem('refresh_attempts');
sessionStorage.removeItem('redirect_count');
throw error;
return false;
}
}
@@ -1075,32 +954,13 @@ async function checkFirstRun() {
}
/**
* Redirect to main application
* Redirect to main application — no token check needed (cookies are opaque).
*/
function redirectToMainApp() {
console.log('Redirecting to main application');
try {
// Reset refresh attempts counter on redirection
localStorage.setItem('refresh_attempts', '0');
sessionStorage.removeItem('redirect_count');
// Set a token expiry if none exists
const tokenExpiry = localStorage.getItem(TOKEN_EXPIRY_KEY);
if (!tokenExpiry) {
const expiryTime = new Date();
expiryTime.setDate(expiryTime.getDate() + 30);
localStorage.setItem(TOKEN_EXPIRY_KEY, expiryTime.toISOString());
}
// Verify we have a valid token before redirecting
const hasToken = localStorage.getItem(TOKEN_KEY);
if (!hasToken) {
console.error('No token found, cannot redirect to app');
return;
}
// Navigate to the main app
window.location.replace('/');
} catch (error) {
console.error('Error during redirect:', error);
@@ -1109,12 +969,15 @@ function redirectToMainApp() {
}
/**
* Logout - clear tokens and redirect to login
* Logout — tell the server to clear HttpOnly cookies, then redirect.
*/
function logout() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(TOKEN_EXPIRY_KEY);
async function logout() {
try {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin', headers: getCsrfHeaders() });
} catch (e) {
console.warn('Logout request failed:', e);
}
localStorage.removeItem(USER_DATA_KEY);
localStorage.removeItem('refresh_attempts');
window.location.href = '/login';
}
+3 -9
View File
@@ -127,9 +127,7 @@ const contextMenus = {
if (window.app.contextMenuTargetFile) {
// Capture reference before context menu cleanup nullifies it
const file = window.app.contextMenuTargetFile;
const token = localStorage.getItem('oxicloud_token');
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
fetch(`/api/files/${file.id}?metadata=true`, { headers })
fetch(`/api/files/${file.id}?metadata=true`, { credentials: 'same-origin' })
.then(response => response.json())
.then(fileDetails => {
// Check if viewable file type (images, PDFs, text files)
@@ -585,8 +583,6 @@ const contextMenus = {
*/
async loadMoveDialogFolders(parentFolderId) {
try {
const token = localStorage.getItem('oxicloud_token');
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
// Ensure we have the home folder ID before proceeding
if (!window.app.userHomeFolderId) {
@@ -606,7 +602,7 @@ const contextMenus = {
const url = `/api/folders/${effectiveParentId}/contents`;
console.log('[Move Dialog] Loading folders from:', url, 'effectiveParentId:', effectiveParentId);
const response = await fetch(url, { headers });
const response = await fetch(url, { credentials: 'same-origin' });
if (!response.ok) {
console.error('Failed to load folders:', response.status);
return;
@@ -1023,9 +1019,7 @@ const contextMenus = {
};
try {
const token = localStorage.getItem('oxicloud_token');
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const headers = { 'Content-Type': 'application/json', ...getCsrfHeaders() };
const response = await fetch('/api/shares', {
method: 'POST',
+8 -11
View File
@@ -4,16 +4,12 @@
*/
/**
* Get authorization headers for API requests
* @returns {Object} Headers object with Authorization bearer token
* Get authorization headers for API requests.
* Tokens are now in HttpOnly cookies — no explicit Authorization header needed.
* @returns {Object} Headers object
*/
function getAuthHeaders() {
const token = localStorage.getItem('oxicloud_token');
const headers = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
return { ...getCsrfHeaders() };
}
// File Operations Module
@@ -177,10 +173,11 @@ const fileOps = {
xhr.open('POST', '/api/files/upload');
// Set auth header
const token = localStorage.getItem('oxicloud_token');
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
// Auth is handled by HttpOnly cookies — no explicit header needed
xhr.setRequestHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
// CSRF double-submit: echo the CSRF cookie as a request header
const _csrfTok = getCsrfToken();
if (_csrfTok) xhr.setRequestHeader('X-CSRF-Token', _csrfTok);
try {
xhr.send(formData);
+5 -18
View File
@@ -210,10 +210,7 @@ class InlineViewer {
try {
console.log('Creating text viewer for:', file.name);
const token = localStorage.getItem('oxicloud_token');
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
const response = await fetch(`/api/files/${file.id}?inline=true`, { headers });
const response = await fetch(`/api/files/${file.id}?inline=true`, { credentials: 'same-origin' });
if (!response.ok) {
throw new Error(`Error fetching file: ${response.status} ${response.statusText}`);
@@ -254,12 +251,7 @@ class InlineViewer {
const xhr = new XMLHttpRequest();
xhr.open('GET', `/api/files/${file.id}?inline=true`, true);
xhr.responseType = 'blob';
// Add auth header
const token = localStorage.getItem('oxicloud_token');
if (token) {
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
}
xhr.withCredentials = true;
// Create a promise to handle the XHR
const response = await new Promise((resolve, reject) => {
@@ -357,10 +349,8 @@ class InlineViewer {
try {
console.log(`Creating ${mediaType} player for:`, file.name);
// Fetch file with auth header (same pattern as images/PDFs)
const token = localStorage.getItem('oxicloud_token');
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
const response = await fetch(`/api/files/${file.id}?inline=true`, { headers });
// Fetch file (cookie auto-sent)
const response = await fetch(`/api/files/${file.id}?inline=true`, { credentials: 'same-origin' });
if (!response.ok) {
throw new Error(`Error fetching file: ${response.status} ${response.statusText}`);
@@ -488,10 +478,7 @@ class InlineViewer {
}
downloadFile(file) {
const token = localStorage.getItem('oxicloud_token');
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
fetch(`/api/files/${file.id}`, { headers })
fetch(`/api/files/${file.id}`, { credentials: 'same-origin' })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
+1 -4
View File
@@ -60,12 +60,9 @@ class WopiEditor {
* Fetch editor URL and WOPI token from the backend.
*/
async _getEditorUrl(fileId, action) {
var token = localStorage.getItem('oxicloud_token') || '';
var response = await fetch(
'/api/wopi/editor-url?file_id=' + encodeURIComponent(fileId) + '&action=' + encodeURIComponent(action),
{
headers: { 'Authorization': 'Bearer ' + token }
}
{ credentials: 'same-origin' }
);
if (!response.ok) {
var text = await response.text();
+1 -4
View File
@@ -16,10 +16,7 @@ const favorites = {
// ───────────────────── helpers ─────────────────────
_authHeaders() {
const token = localStorage.getItem('oxicloud_token');
const h = {};
if (token) h['Authorization'] = `Bearer ${token}`;
return h;
return { ...getCsrfHeaders() };
},
_cacheKey(id, type) {
+1 -4
View File
@@ -13,10 +13,7 @@ const recent = {
// ───────────────────── helpers ─────────────────────
_authHeaders() {
const token = localStorage.getItem('oxicloud_token');
const h = {};
if (token) h['Authorization'] = `Bearer ${token}`;
return h;
return { ...getCsrfHeaders() };
},
// ───────────────────── lifecycle ─────────────────────
+2 -4
View File
@@ -5,11 +5,9 @@
*/
const fileSharing = {
/** Auth header helper */
/** Auth header helper — tokens are in HttpOnly cookies now */
_headers(json = true) {
const h = {};
const token = localStorage.getItem('oxicloud_token');
if (token) h['Authorization'] = `Bearer ${token}`;
const h = { ...getCsrfHeaders() };
if (json) h['Content-Type'] = 'application/json';
return h;
},
+1 -3
View File
@@ -1,5 +1,4 @@
const API = '/api';
const token = localStorage.getItem('oxicloud_token') || localStorage.getItem('token') || localStorage.getItem('access_token');
let currentAdminId = '';
let usersPage = 0;
const PAGE_SIZE = 50;
@@ -24,7 +23,7 @@ function showElement(id, mode = 'block') {
}
function headers() {
return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
return { 'Content-Type': 'application/json', ...getCsrfHeaders() };
}
function formatBytes(bytes) {
@@ -357,7 +356,6 @@ async function saveOidcSettings() {
}
async function init() {
if (!token) { showAccessDenied(); return; }
try {
const me = await fetch(API + '/auth/me', { headers: headers() });
if (!me.ok) { showAccessDenied(); return; }
+1 -3
View File
@@ -1,8 +1,7 @@
const API = '/api';
const token = localStorage.getItem('oxicloud_token') || localStorage.getItem('token') || localStorage.getItem('access_token');
function headers() {
return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
return { 'Content-Type': 'application/json', ...getCsrfHeaders() };
}
function formatBytes(bytes) {
@@ -25,7 +24,6 @@ function timeAgo(dateStr) {
}
async function init() {
if (!token) { showError(); return; }
try {
const resp = await fetch(API + '/auth/me', { headers: headers() });
if (!resp.ok) { showError(); return; }
+2 -4
View File
@@ -9,11 +9,9 @@ const sharedView = {
filteredItems: [],
currentItem: null,
/** Auth header helper */
/** Auth header helper — tokens are in HttpOnly cookies now */
_headers(json = false) {
const h = {};
const token = localStorage.getItem('oxicloud_token');
if (token) h['Authorization'] = `Bearer ${token}`;
const h = { ...getCsrfHeaders() };
if (json) h['Content-Type'] = 'application/json';
return h;
},
+1
View File
@@ -13,6 +13,7 @@
<!-- Scripts -->
<script src="/js/core/i18n.js"></script>
<script src="/js/core/csrf.js"></script>
<script src="/js/core/icons.js" defer></script>
<script src="/js/features/auth/auth.js" defer></script>
</head>
+1
View File
@@ -6,6 +6,7 @@
<title>OxiCloud — My Profile</title>
<script>if(localStorage.getItem('oxicloud_theme')==='dark')document.documentElement.setAttribute('data-theme','dark');</script>
<script src="/js/core/icons.js" defer></script>
<script src="/js/core/csrf.js" defer></script>
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/views/profile.css">
</head>