fix csrf token hardset, remove cookie from localstorage

This commit is contained in:
Zimeng Xiong
2026-02-10 13:16:04 -08:00
parent 1117dc584e
commit bb028ef2db
23 changed files with 412 additions and 145 deletions
+3
View File
@@ -123,6 +123,7 @@ docker compose up -d
When running ExcaliDash behind Traefik, Nginx, or another reverse proxy, configure both containers so that API + WebSocket calls resolve correctly:
- `FRONTEND_URL` (backend) must match the public URL that users hit (e.g. `https://excalidash.example.com`). This controls CORS and Socket.IO origin checks. **Supports multiple comma-separated URLs** for accessing from different addresses.
- `TRUST_PROXY` (backend) should be set to `1` when requests pass through one reverse proxy hop (for example: frontend nginx -> backend). This ensures rate limiting and logging use the real client IP from trusted proxy headers.
- `BACKEND_URL` (frontend) tells the Nginx container how to reach the backend from inside Docker/Kubernetes. Override it if your reverse proxy exposes the backend under a different hostname.
```yaml
@@ -131,6 +132,8 @@ backend:
environment:
# Single URL
- FRONTEND_URL=https://excalidash.example.com
# Trust exactly one reverse-proxy hop
- TRUST_PROXY=1
# Or multiple URLs (comma-separated) for local + network access
# - FRONTEND_URL=http://localhost:6767,http://192.168.1.100:6767,http://nas.local:6767
frontend:
+1
View File
@@ -3,6 +3,7 @@ PORT=8000
NODE_ENV=production
DATABASE_URL=file:/app/prisma/dev.db
FRONTEND_URL=http://localhost:6767
TRUST_PROXY=1
JWT_SECRET=change-this-secret-in-production-min-32-chars
# Optional Feature Flags (all default to false for backward compatibility)
+15
View File
@@ -21,6 +21,13 @@ import {
type AuthModeService,
} from "./auth/authMode";
import { getCsrfValidationClientIds } from "./security/csrfClient";
import {
clearAuthCookies,
readCookie,
REFRESH_TOKEN_COOKIE_NAME,
setAccessTokenCookie,
setAuthCookies,
} from "./auth/cookies";
interface JwtPayload {
userId: string;
@@ -380,6 +387,10 @@ export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router =>
bootstrapUserId: BOOTSTRAP_USER_ID,
defaultSystemConfigId: DEFAULT_SYSTEM_CONFIG_ID,
clearAuthEnabledCache: authModeService.clearAuthEnabledCache,
setAuthCookies,
setAccessTokenCookie,
clearAuthCookies,
readRefreshTokenFromRequest: (req) => readCookie(req, REFRESH_TOKEN_COOKIE_NAME),
});
registerAdminRoutes({
@@ -401,6 +412,8 @@ export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router =>
getRefreshTokenExpiresAt,
config,
defaultSystemConfigId: DEFAULT_SYSTEM_CONFIG_ID,
setAuthCookies,
requireCsrf,
});
registerAccountRoutes({
@@ -414,6 +427,8 @@ export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router =>
config,
generateTokens,
getRefreshTokenExpiresAt,
setAuthCookies,
requireCsrf,
});
return router;
+14
View File
@@ -34,6 +34,12 @@ type RegisterAccountRoutesDeps = {
options?: { impersonatorId?: string }
) => { accessToken: string; refreshToken: string };
getRefreshTokenExpiresAt: () => Date;
setAuthCookies: (
req: Request,
res: Response,
tokens: { accessToken: string; refreshToken: string }
) => void;
requireCsrf: (req: Request, res: Response) => boolean;
};
export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
@@ -48,6 +54,8 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
config,
generateTokens,
getRefreshTokenExpiresAt,
setAuthCookies,
requireCsrf,
} = deps;
router.post("/password-reset-request", loginAttemptRateLimiter, async (req: Request, res: Response) => {
@@ -210,6 +218,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
router.put("/profile", requireAuth, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!req.user) {
return res.status(401).json({
error: "Unauthorized",
@@ -261,6 +270,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
router.put("/email", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!req.user) {
return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
}
@@ -347,6 +357,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
}
const { accessToken, refreshToken } = generateTokens(updatedUser.id, updatedUser.email);
setAuthCookies(req, res, { accessToken, refreshToken });
if (config.enableRefreshTokenRotation) {
const expiresAt = getRefreshTokenExpiresAt();
try {
@@ -387,6 +398,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
router.post("/change-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!req.user) {
return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
}
@@ -463,6 +475,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
router.post("/must-reset-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!req.user) {
return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
}
@@ -528,6 +541,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
}
const { accessToken, refreshToken } = generateTokens(updatedUser.id, updatedUser.email);
setAuthCookies(req, res, { accessToken, refreshToken });
if (config.enableRefreshTokenRotation) {
const expiresAt = getRefreshTokenExpiresAt();
try {
+17
View File
@@ -64,6 +64,12 @@ type RegisterAdminRoutesDeps = {
enableRefreshTokenRotation: boolean;
};
defaultSystemConfigId: string;
setAuthCookies: (
req: Request,
res: Response,
tokens: { accessToken: string; refreshToken: string }
) => void;
requireCsrf: (req: Request, res: Response) => boolean;
};
export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
@@ -86,11 +92,14 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
getRefreshTokenExpiresAt,
config,
defaultSystemConfigId,
setAuthCookies,
requireCsrf,
} = deps;
router.post("/registration/toggle", requireAuth, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!requireAdmin(req, res)) return;
const parsed = registrationToggleSchema.safeParse(req.body);
@@ -117,6 +126,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
router.post("/admins", requireAuth, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!requireAdmin(req, res)) return;
const parsed = adminRoleUpdateSchema.safeParse(req.body);
@@ -220,6 +230,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
router.put("/rate-limit/login", requireAuth, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!requireAdmin(req, res)) return;
const parsed = loginRateLimitUpdateSchema.safeParse(req.body);
@@ -265,6 +276,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
router.post("/rate-limit/login/reset", requireAuth, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!requireAdmin(req, res)) return;
const parsed = loginRateLimitResetSchema.safeParse(req.body);
@@ -302,6 +314,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
router.post("/users", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!requireAdmin(req, res)) return;
const parsed = adminCreateUserSchema.safeParse(req.body);
@@ -386,6 +399,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
router.patch("/users/:id", requireAuth, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!requireAdmin(req, res)) return;
const userId = String(req.params.id || "").trim();
@@ -494,6 +508,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
router.post("/users/:id/reset-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!requireAdmin(req, res)) return;
if (req.user.impersonatorId) {
@@ -583,6 +598,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
router.post("/impersonate", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!requireAdmin(req, res)) return;
const parsed = impersonateSchema.safeParse(req.body);
@@ -606,6 +622,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
const { accessToken, refreshToken } = generateTokens(target.id, target.email, {
impersonatorId: req.user.id,
});
setAuthCookies(req, res, { accessToken, refreshToken });
if (config.enableRefreshTokenRotation) {
const expiresAt = getRefreshTokenExpiresAt();
+106
View File
@@ -0,0 +1,106 @@
import type { Request, Response } from "express";
import ms, { type StringValue } from "ms";
import { config } from "../config";
export const ACCESS_TOKEN_COOKIE_NAME = "excalidash-access-token";
export const REFRESH_TOKEN_COOKIE_NAME = "excalidash-refresh-token";
const DEFAULT_ACCESS_TTL_MS = 15 * 60 * 1000;
const DEFAULT_REFRESH_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const parseDurationToMs = (value: string, fallbackMs: number): number => {
const parsed = ms(value as StringValue);
if (typeof parsed === "number" && Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
return fallbackMs;
};
const ACCESS_TOKEN_COOKIE_MAX_AGE_MS = parseDurationToMs(
config.jwtAccessExpiresIn,
DEFAULT_ACCESS_TTL_MS
);
const REFRESH_TOKEN_COOKIE_MAX_AGE_MS = parseDurationToMs(
config.jwtRefreshExpiresIn,
DEFAULT_REFRESH_TTL_MS
);
const requestUsesHttps = (req: Request): boolean => {
if (req.secure) return true;
const forwardedProto = req.headers["x-forwarded-proto"];
const raw = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto;
const firstHop = String(raw || "")
.split(",")[0]
.trim()
.toLowerCase();
return firstHop === "https";
};
const shouldUseSecureCookies = (req: Request): boolean => requestUsesHttps(req);
const baseCookieOptions = (req: Request) => ({
httpOnly: true,
secure: shouldUseSecureCookies(req),
sameSite: "lax" as const,
path: "/",
});
export const setAuthCookies = (
req: Request,
res: Response,
tokens: { accessToken: string; refreshToken: string }
): void => {
res.cookie(ACCESS_TOKEN_COOKIE_NAME, tokens.accessToken, {
...baseCookieOptions(req),
maxAge: ACCESS_TOKEN_COOKIE_MAX_AGE_MS,
});
res.cookie(REFRESH_TOKEN_COOKIE_NAME, tokens.refreshToken, {
...baseCookieOptions(req),
maxAge: REFRESH_TOKEN_COOKIE_MAX_AGE_MS,
});
};
export const setAccessTokenCookie = (
req: Request,
res: Response,
accessToken: string
): void => {
res.cookie(ACCESS_TOKEN_COOKIE_NAME, accessToken, {
...baseCookieOptions(req),
maxAge: ACCESS_TOKEN_COOKIE_MAX_AGE_MS,
});
};
export const clearAuthCookies = (req: Request, res: Response): void => {
const options = baseCookieOptions(req);
res.clearCookie(ACCESS_TOKEN_COOKIE_NAME, options);
res.clearCookie(REFRESH_TOKEN_COOKIE_NAME, options);
};
export const parseCookieHeader = (
cookieHeader: string | undefined
): Record<string, string> => {
if (!cookieHeader) return {};
const cookies: Record<string, string> = {};
for (const part of cookieHeader.split(";")) {
const [rawKey, ...rawValueParts] = part.split("=");
if (!rawKey || rawValueParts.length === 0) continue;
const key = rawKey.trim();
if (!key) continue;
const rawValue = rawValueParts.join("=").trim();
try {
cookies[key] = decodeURIComponent(rawValue);
} catch {
cookies[key] = rawValue;
}
}
return cookies;
};
export const readCookie = (req: Request, cookieName: string): string | null => {
const cookies = parseCookieHeader(req.headers.cookie);
const value = cookies[cookieName];
if (!value || value.trim().length === 0) return null;
return value;
};
+133 -1
View File
@@ -57,6 +57,14 @@ type RegisterCoreRoutesDeps = {
bootstrapUserId: string;
defaultSystemConfigId: string;
clearAuthEnabledCache: () => void;
setAuthCookies: (
req: Request,
res: Response,
tokens: { accessToken: string; refreshToken: string }
) => void;
setAccessTokenCookie: (req: Request, res: Response, accessToken: string) => void;
clearAuthCookies: (req: Request, res: Response) => void;
readRefreshTokenFromRequest: (req: Request) => string | null;
};
class HttpError extends Error {
@@ -88,6 +96,10 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
bootstrapUserId,
defaultSystemConfigId,
clearAuthEnabledCache,
setAuthCookies,
setAccessTokenCookie,
clearAuthCookies,
readRefreshTokenFromRequest,
} = deps;
const getUserTrashCollectionId = (userId: string): string => `trash:${userId}`;
@@ -158,6 +170,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
}
const { accessToken, refreshToken } = generateTokens(user.id, user.email);
setAuthCookies(req, res, { accessToken, refreshToken });
if (config.enableRefreshTokenRotation) {
const expiresAt = getRefreshTokenExpiresAt();
@@ -257,6 +270,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
}
const { accessToken, refreshToken } = generateTokens(user.id, user.email);
setAuthCookies(req, res, { accessToken, refreshToken });
if (config.enableRefreshTokenRotation) {
const expiresAt = getRefreshTokenExpiresAt();
@@ -375,6 +389,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
}
const { accessToken, refreshToken } = generateTokens(user.id, user.email);
setAuthCookies(req, res, { accessToken, refreshToken });
if (config.enableRefreshTokenRotation) {
const expiresAt = getRefreshTokenExpiresAt();
@@ -431,7 +446,9 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
router.post("/refresh", async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
const { refreshToken: oldRefreshToken } = req.body;
const oldRefreshTokenFromBody =
typeof req.body?.refreshToken === "string" ? req.body.refreshToken : null;
const oldRefreshToken = oldRefreshTokenFromBody || readRefreshTokenFromRequest(req);
if (!oldRefreshToken || typeof oldRefreshToken !== "string") {
return res.status(400).json({
@@ -513,6 +530,10 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
});
});
setAuthCookies(req, res, {
accessToken,
refreshToken: newRefreshToken,
});
return res.json({
accessToken,
refreshToken: newRefreshToken,
@@ -555,6 +576,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
signOptions
);
setAccessTokenCookie(req, res, accessToken);
res.json({ accessToken });
} catch {
return res.status(401).json({
@@ -571,6 +593,116 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
}
});
router.post("/logout", optionalAuth, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
clearAuthCookies(req, res);
if (config.enableRefreshTokenRotation && req.user?.id) {
await prisma.refreshToken.updateMany({
where: { userId: req.user.id, revoked: false },
data: { revoked: true },
});
}
return res.json({ ok: true });
} catch (error) {
console.error("Logout error:", error);
return res.status(500).json({
error: "Internal server error",
message: "Failed to logout",
});
}
});
router.post("/stop-impersonation", requireAuth, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
if (!requireCsrf(req, res)) return;
if (!req.user) {
return res.status(401).json({
error: "Unauthorized",
message: "User not authenticated",
});
}
if (!req.user.impersonatorId) {
return res.status(409).json({
error: "Conflict",
message: "Not currently impersonating another user",
});
}
const impersonator = await prisma.user.findUnique({
where: { id: req.user.impersonatorId },
select: {
id: true,
username: true,
email: true,
name: true,
role: true,
mustResetPassword: true,
isActive: true,
},
});
if (!impersonator || !impersonator.isActive || impersonator.role !== "ADMIN") {
return res.status(403).json({
error: "Forbidden",
message: "Impersonator account is unavailable or no longer authorized",
});
}
const { accessToken, refreshToken } = generateTokens(
impersonator.id,
impersonator.email
);
setAuthCookies(req, res, { accessToken, refreshToken });
if (config.enableRefreshTokenRotation) {
const expiresAt = getRefreshTokenExpiresAt();
try {
await prisma.refreshToken.create({
data: {
userId: impersonator.id,
token: hashTokenForStorage(refreshToken),
expiresAt,
},
});
} catch (error) {
if (isMissingRefreshTokenTableError(error)) {
return res.status(503).json({
error: "Service unavailable",
message: "Refresh token storage is unavailable. Please run database migrations.",
});
}
throw error;
}
}
return res.json({
user: {
id: impersonator.id,
username: impersonator.username,
email: impersonator.email,
name: impersonator.name,
role: impersonator.role,
mustResetPassword: impersonator.mustResetPassword,
},
accessToken,
refreshToken,
});
} catch (error) {
console.error("Stop impersonation error:", error);
return res.status(500).json({
error: "Internal server error",
message: "Failed to stop impersonation",
});
}
});
router.get("/me", requireAuth, async (req: Request, res: Response) => {
try {
if (!(await ensureAuthEnabled(res))) return;
+21 -5
View File
@@ -1,9 +1,25 @@
import { z } from "zod";
const productionStrongPasswordMessage =
"Password must be at least 12 characters and include upper, lower, number, and symbol";
const strongPasswordPattern =
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{12,100}$/;
const passwordSchema = z
.string()
.min(8)
.max(100)
.refine(
(value) =>
process.env.NODE_ENV !== "production" || strongPasswordPattern.test(value),
{ message: productionStrongPasswordMessage }
);
export const registerSchema = z.object({
username: z.string().trim().min(3).max(50).optional(),
email: z.string().email().toLowerCase().trim(),
password: z.string().min(8).max(100),
password: passwordSchema,
name: z.string().trim().min(1).max(100),
});
@@ -34,7 +50,7 @@ export const authEnabledToggleSchema = z.object({
export const adminCreateUserSchema = z.object({
username: z.string().trim().min(3).max(50).optional(),
email: z.string().email().toLowerCase().trim(),
password: z.string().min(8).max(100),
password: passwordSchema,
name: z.string().trim().min(1).max(100),
role: z.enum(["ADMIN", "USER"]).optional(),
mustResetPassword: z.boolean().optional(),
@@ -74,7 +90,7 @@ export const passwordResetRequestSchema = z.object({
export const passwordResetConfirmSchema = z.object({
token: z.string().min(1),
password: z.string().min(8).max(100),
password: passwordSchema,
});
export const updateProfileSchema = z.object({
@@ -88,9 +104,9 @@ export const updateEmailSchema = z.object({
export const changePasswordSchema = z.object({
currentPassword: z.string(),
newPassword: z.string().min(8).max(100),
newPassword: passwordSchema,
});
export const mustResetPasswordSchema = z.object({
newPassword: z.string().min(8).max(100),
newPassword: passwordSchema,
});
+11 -2
View File
@@ -118,11 +118,20 @@ export const config: Config = {
// Validate JWT_SECRET strength in production
if (config.nodeEnv === "production") {
const normalizedSecret = config.jwtSecret.trim();
const insecureJwtSecretPlaceholders = new Set([
"your-secret-key-change-in-production",
"change-this-secret-in-production-min-32-chars",
]);
if (config.jwtSecret.length < 32) {
throw new Error("JWT_SECRET must be at least 32 characters long in production");
}
if (config.jwtSecret === "your-secret-key-change-in-production") {
throw new Error("JWT_SECRET must be changed from default value in production");
if (
insecureJwtSecretPlaceholders.has(normalizedSecret) ||
normalizedSecret.toLowerCase().includes("change-this-secret")
) {
throw new Error("JWT_SECRET must be changed from placeholder/default value in production");
}
}
+10 -6
View File
@@ -109,15 +109,19 @@ const initializeUploadDir = async () => {
const app = express();
// Trust proxy headers (X-Forwarded-For, X-Real-IP) from nginx.
// Default to a single trusted proxy hop unless TRUST_PROXY is explicitly configured.
// Set TRUST_PROXY=true only when you fully trust all upstream proxy hops.
const trustProxyConfig = (process.env.TRUST_PROXY ?? "1").trim();
const trustProxyValue = trustProxyConfig === "true"
// Trust proxy headers (X-Forwarded-For, X-Real-IP) only when explicitly configured.
// Safe default is disabled to avoid spoofed client IPs when running without a trusted proxy.
// Set TRUST_PROXY=1 (or a specific hop count) when deploying behind reverse proxies.
const trustProxyConfig = (process.env.TRUST_PROXY ?? "false").trim();
const parsedProxyHops = Number.parseInt(trustProxyConfig, 10);
const trustProxyValue =
trustProxyConfig === "true"
? true
: trustProxyConfig === "false"
? false
: Number.parseInt(trustProxyConfig, 10) || 1;
: Number.isFinite(parsedProxyHops) && parsedProxyHops > 0
? parsedProxyHops
: false;
app.set("trust proxy", trustProxyValue);
if (trustProxyValue === true) {
+6 -5
View File
@@ -4,6 +4,7 @@ import { config } from "../config";
import { PrismaClient } from "../generated/client";
import { prisma as defaultPrisma } from "../db/prisma";
import { createAuthModeService, type AuthModeService } from "../auth/authMode";
import { ACCESS_TOKEN_COOKIE_NAME, readCookie } from "../auth/cookies";
// Extend Express Request type to include user
declare global {
@@ -46,14 +47,14 @@ const isJwtPayload = (decoded: unknown): decoded is JwtPayload => {
const extractToken = (req: Request): string | null => {
const authHeader = req.headers.authorization;
if (!authHeader || typeof authHeader !== "string") return null;
if (authHeader && typeof authHeader === "string") {
const parts = authHeader.split(" ");
if (parts.length !== 2 || parts[0] !== "Bearer") {
return null;
if (parts.length === 2 && parts[0] === "Bearer") {
return parts[1] || null;
}
}
return parts[1];
return readCookie(req, ACCESS_TOKEN_COOKIE_NAME);
};
const verifyToken = (token: string): JwtPayload | null => {
+8 -1
View File
@@ -2,6 +2,7 @@ import jwt from "jsonwebtoken";
import { Server } from "socket.io";
import { PrismaClient } from "../generated/client";
import { AuthModeService } from "../auth/authMode";
import { ACCESS_TOKEN_COOKIE_NAME, parseCookieHeader } from "../auth/cookies";
interface User {
id: string;
@@ -87,7 +88,13 @@ export const registerSocketHandlers = ({
io.use(async (socket, next) => {
try {
const token = socket.handshake.auth?.token as string | undefined;
const tokenFromAuth = socket.handshake.auth?.token as string | undefined;
const tokenFromCookie = (() => {
const cookies = parseCookieHeader(socket.handshake.headers.cookie);
const value = cookies[ACCESS_TOKEN_COOKIE_NAME];
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
})();
const token = tokenFromAuth || tokenFromCookie;
const userId = await getSocketAuthUserId(token);
if (!userId) {
+1
View File
@@ -6,6 +6,7 @@ services:
- DATABASE_URL=file:/app/prisma/dev.db
- PORT=8000
- NODE_ENV=production
- TRUST_PROXY=1
# Optional for single-instance deployments:
# if unset, backend auto-generates and persists one in the volume.
# Recommended to set explicitly for portability and multi-instance setups.
+1
View File
@@ -8,6 +8,7 @@ services:
- DATABASE_URL=file:/app/prisma/dev.db
- PORT=8000
- NODE_ENV=production
- TRUST_PROXY=1
# Optional for single-instance deployments:
# if unset, backend auto-generates and persists one in the volume.
# Recommended to set explicitly for portability and multi-instance setups.
+15 -38
View File
@@ -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();
+2 -10
View File
@@ -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();
});
});
+11 -37
View File
@@ -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(storedToken);
const response = await authMe();
setUser(response.user);
localStorage.setItem(USER_KEY, JSON.stringify(response.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);
await authRefresh();
const userResponse = await authMe();
setUser(userResponse.user);
localStorage.setItem(USER_KEY, JSON.stringify(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);
}
}
}
} 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(() => {
+21 -12
View File
@@ -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;
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) {
+1 -1
View File
@@ -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">
+1 -2
View File
@@ -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;
+1 -3
View File
@@ -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 = '/';
+1 -3
View File
@@ -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 -8
View File
@@ -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;
};