Enhance authentication handling with secure cookie management

- Added support for configurable cookie security modes in the authentication logic, allowing for flexible handling of secure cookies based on the request scheme.
- Updated session management to respect the secure cookie flag based on the environment and request context.
- Modified the Fastify server setup to honor reverse proxy headers for improved cookie security in various deployment scenarios.
- Enhanced documentation in the codebase to clarify the new cookie security options and their implications.
This commit is contained in:
2026-08-03 15:14:39 +07:00
parent f2a4e8958c
commit a92e8e2775
3 changed files with 86 additions and 7 deletions
+5
View File
@@ -15,6 +15,11 @@ services:
# AUTH_PASSWORD: change-me # AUTH_PASSWORD: change-me
# SESSION_SECRET: long-random-string-at-least-32-chars # SESSION_SECRET: long-random-string-at-least-32-chars
# SESSION_TTL_HOURS: "168" # SESSION_TTL_HOURS: "168"
# Cookie Secure: auto (default) follows http/https per request — use for WireGuard IP + public HTTPS.
# COOKIE_SECURE: auto
# Force: always | never (or true | false)
# Behind a TLS-terminating reverse proxy, keep TRUST_PROXY on so X-Forwarded-Proto is honored.
# TRUST_PROXY: "true"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
restart: unless-stopped restart: unless-stopped
+73 -5
View File
@@ -7,6 +7,10 @@ const PUBLIC_AUTH_PATHS = new Set([
"POST /api/auth/logout", "POST /api/auth/logout",
]); ]);
/**
* @typedef {"auto"|"always"|"never"} CookieSecureMode
*/
/** /**
* @typedef {object} AuthConfig * @typedef {object} AuthConfig
* @property {boolean} enabled - Whether AUTH_PASSWORD is set. * @property {boolean} enabled - Whether AUTH_PASSWORD is set.
@@ -14,8 +18,63 @@ const PUBLIC_AUTH_PATHS = new Set([
* @property {string} password - Expected password when enabled. * @property {string} password - Expected password when enabled.
* @property {string} secret - HMAC secret for session cookies. * @property {string} secret - HMAC secret for session cookies.
* @property {number} ttlMs - Session lifetime in milliseconds. * @property {number} ttlMs - Session lifetime in milliseconds.
* @property {CookieSecureMode} cookieSecureMode - How to set the Secure cookie flag.
*/ */
/**
* Parses COOKIE_SECURE for dual HTTP/HTTPS access (e.g. WireGuard IP + public domain).
* - unset / "auto": Secure follows the request scheme (https → Secure)
* - "true" / "1" / "always": always Secure
* - "false" / "0" / "never": never Secure
* @returns {CookieSecureMode} Cookie Secure policy.
*/
function parseCookieSecureMode() {
const raw = (process.env.COOKIE_SECURE || "").trim().toLowerCase();
if (raw === "true" || raw === "1" || raw === "always") {
return "always";
}
if (raw === "false" || raw === "0" || raw === "never") {
return "never";
}
return "auto";
}
/**
* Whether this request was made over HTTPS (direct TLS or reverse-proxy proto).
* With Fastify trustProxy enabled, request.protocol reflects X-Forwarded-Proto.
* @param {import("fastify").FastifyRequest} request - Incoming request.
* @returns {boolean} True when the client connection is HTTPS.
*/
function requestIsHttps(request) {
if (request.protocol === "https") {
return true;
}
const forwarded = request.headers["x-forwarded-proto"];
if (typeof forwarded === "string" && forwarded.length > 0) {
return forwarded.split(",")[0].trim().toLowerCase() === "https";
}
if (Array.isArray(forwarded) && forwarded.length > 0) {
return String(forwarded[0]).split(",")[0].trim().toLowerCase() === "https";
}
return false;
}
/**
* Resolves the Secure flag for a Set-Cookie on this request.
* @param {import("fastify").FastifyRequest} request - Incoming request.
* @param {AuthConfig} config - Auth config.
* @returns {boolean} Whether the cookie should include Secure.
*/
function resolveCookieSecure(request, config) {
if (config.cookieSecureMode === "always") {
return true;
}
if (config.cookieSecureMode === "never") {
return false;
}
return requestIsHttps(request);
}
/** /**
* Reads auth settings from the environment. * Reads auth settings from the environment.
* @returns {AuthConfig} Parsed config (enabled=false when AUTH_PASSWORD is empty). * @returns {AuthConfig} Parsed config (enabled=false when AUTH_PASSWORD is empty).
@@ -41,6 +100,7 @@ function loadAuthConfig() {
password, password,
secret, secret,
ttlMs: Math.round(ttlHours * 60 * 60 * 1000), ttlMs: Math.round(ttlHours * 60 * 60 * 1000),
cookieSecureMode: parseCookieSecureMode(),
}; };
} }
@@ -130,16 +190,17 @@ function sessionFromRequest(request, config) {
/** /**
* Cookie options for setting/clearing the session. * Cookie options for setting/clearing the session.
* @param {AuthConfig} config - Auth config. * @param {AuthConfig} config - Auth config.
* @param {import("fastify").FastifyRequest} request - Request used for Secure auto-detect.
* @param {number} [maxAgeMs] - Max-Age in ms (omit when clearing). * @param {number} [maxAgeMs] - Max-Age in ms (omit when clearing).
* @returns {import("@fastify/cookie").CookieSerializeOptions} Cookie options. * @returns {import("@fastify/cookie").CookieSerializeOptions} Cookie options.
*/ */
function sessionCookieOptions(config, maxAgeMs) { function sessionCookieOptions(config, request, maxAgeMs) {
/** @type {import("@fastify/cookie").CookieSerializeOptions} */ /** @type {import("@fastify/cookie").CookieSerializeOptions} */
const opts = { const opts = {
path: "/", path: "/",
httpOnly: true, httpOnly: true,
sameSite: "lax", sameSite: "lax",
secure: process.env.NODE_ENV === "production", secure: resolveCookieSecure(request, config),
}; };
if (typeof maxAgeMs === "number") { if (typeof maxAgeMs === "number") {
opts.maxAge = Math.floor(maxAgeMs / 1000); opts.maxAge = Math.floor(maxAgeMs / 1000);
@@ -197,7 +258,11 @@ function registerAuth(app, config) {
const expiresAt = Date.now() + config.ttlMs; const expiresAt = Date.now() + config.ttlMs;
const token = createSessionToken(config.username, config.secret, expiresAt); const token = createSessionToken(config.username, config.secret, expiresAt);
reply.setCookie(COOKIE_NAME, token, sessionCookieOptions(config, config.ttlMs)); reply.setCookie(
COOKIE_NAME,
token,
sessionCookieOptions(config, request, config.ttlMs),
);
return { return {
ok: true, ok: true,
@@ -207,8 +272,8 @@ function registerAuth(app, config) {
}; };
}); });
app.post("/api/auth/logout", async (_request, reply) => { app.post("/api/auth/logout", async (request, reply) => {
reply.clearCookie(COOKIE_NAME, sessionCookieOptions(config)); reply.clearCookie(COOKIE_NAME, sessionCookieOptions(config, request));
return { ok: true, authenticated: false }; return { ok: true, authenticated: false };
}); });
@@ -240,8 +305,11 @@ module.exports = {
COOKIE_NAME, COOKIE_NAME,
createSessionToken, createSessionToken,
loadAuthConfig, loadAuthConfig,
parseCookieSecureMode,
parseSessionToken, parseSessionToken,
registerAuth, registerAuth,
requestIsHttps,
resolveCookieSecure,
sessionCookieOptions, sessionCookieOptions,
sessionFromRequest, sessionFromRequest,
verifyPassword, verifyPassword,
+8 -2
View File
@@ -29,7 +29,12 @@ async function main() {
const db = openDatabase(DATA_DIR); const db = openDatabase(DATA_DIR);
const queue = new DownloadQueue(db); const queue = new DownloadQueue(db);
const authConfig = loadAuthConfig(); const authConfig = loadAuthConfig();
const app = Fastify({ logger: true }); // Honor X-Forwarded-Proto / X-Forwarded-Host when behind a reverse proxy so
// session cookies can set Secure automatically on the public HTTPS domain
// while still working over plain HTTP (e.g. WireGuard IP).
const trustProxyRaw = (process.env.TRUST_PROXY || "true").trim().toLowerCase();
const trustProxy = !(trustProxyRaw === "false" || trustProxyRaw === "0");
const app = Fastify({ logger: true, trustProxy });
// Allow POST/PATCH with Content-Type: application/json and an empty body // Allow POST/PATCH with Content-Type: application/json and an empty body
app.addContentTypeParser( app.addContentTypeParser(
@@ -90,9 +95,10 @@ async function main() {
app.log.info(`Data dir: ${DATA_DIR}`); app.log.info(`Data dir: ${DATA_DIR}`);
app.log.info(`Watch cron: ${WATCH_CRON}`); app.log.info(`Watch cron: ${WATCH_CRON}`);
app.log.info(`Watch title interval: ${String(WATCH_TITLE_INTERVAL_HOURS)}h (one title per tick)`); app.log.info(`Watch title interval: ${String(WATCH_TITLE_INTERVAL_HOURS)}h (one title per tick)`);
app.log.info(`Trust proxy: ${trustProxy}`);
app.log.info( app.log.info(
authConfig.enabled authConfig.enabled
? `Auth: enabled (user=${authConfig.username})` ? `Auth: enabled (user=${authConfig.username}, cookieSecure=${authConfig.cookieSecureMode})`
: "Auth: disabled (set AUTH_PASSWORD to enable)", : "Auth: disabled (set AUTH_PASSWORD to enable)",
); );
} }