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:
@@ -15,6 +15,11 @@ services:
|
||||
# AUTH_PASSWORD: change-me
|
||||
# SESSION_SECRET: long-random-string-at-least-32-chars
|
||||
# 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:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
+73
-5
@@ -7,6 +7,10 @@ const PUBLIC_AUTH_PATHS = new Set([
|
||||
"POST /api/auth/logout",
|
||||
]);
|
||||
|
||||
/**
|
||||
* @typedef {"auto"|"always"|"never"} CookieSecureMode
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} AuthConfig
|
||||
* @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} secret - HMAC secret for session cookies.
|
||||
* @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.
|
||||
* @returns {AuthConfig} Parsed config (enabled=false when AUTH_PASSWORD is empty).
|
||||
@@ -41,6 +100,7 @@ function loadAuthConfig() {
|
||||
password,
|
||||
secret,
|
||||
ttlMs: Math.round(ttlHours * 60 * 60 * 1000),
|
||||
cookieSecureMode: parseCookieSecureMode(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,16 +190,17 @@ function sessionFromRequest(request, config) {
|
||||
/**
|
||||
* Cookie options for setting/clearing the session.
|
||||
* @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).
|
||||
* @returns {import("@fastify/cookie").CookieSerializeOptions} Cookie options.
|
||||
*/
|
||||
function sessionCookieOptions(config, maxAgeMs) {
|
||||
function sessionCookieOptions(config, request, maxAgeMs) {
|
||||
/** @type {import("@fastify/cookie").CookieSerializeOptions} */
|
||||
const opts = {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
secure: resolveCookieSecure(request, config),
|
||||
};
|
||||
if (typeof maxAgeMs === "number") {
|
||||
opts.maxAge = Math.floor(maxAgeMs / 1000);
|
||||
@@ -197,7 +258,11 @@ function registerAuth(app, config) {
|
||||
|
||||
const expiresAt = Date.now() + config.ttlMs;
|
||||
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 {
|
||||
ok: true,
|
||||
@@ -207,8 +272,8 @@ function registerAuth(app, config) {
|
||||
};
|
||||
});
|
||||
|
||||
app.post("/api/auth/logout", async (_request, reply) => {
|
||||
reply.clearCookie(COOKIE_NAME, sessionCookieOptions(config));
|
||||
app.post("/api/auth/logout", async (request, reply) => {
|
||||
reply.clearCookie(COOKIE_NAME, sessionCookieOptions(config, request));
|
||||
return { ok: true, authenticated: false };
|
||||
});
|
||||
|
||||
@@ -240,8 +305,11 @@ module.exports = {
|
||||
COOKIE_NAME,
|
||||
createSessionToken,
|
||||
loadAuthConfig,
|
||||
parseCookieSecureMode,
|
||||
parseSessionToken,
|
||||
registerAuth,
|
||||
requestIsHttps,
|
||||
resolveCookieSecure,
|
||||
sessionCookieOptions,
|
||||
sessionFromRequest,
|
||||
verifyPassword,
|
||||
|
||||
+8
-2
@@ -29,7 +29,12 @@ async function main() {
|
||||
const db = openDatabase(DATA_DIR);
|
||||
const queue = new DownloadQueue(db);
|
||||
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
|
||||
app.addContentTypeParser(
|
||||
@@ -90,9 +95,10 @@ async function main() {
|
||||
app.log.info(`Data dir: ${DATA_DIR}`);
|
||||
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(`Trust proxy: ${trustProxy}`);
|
||||
app.log.info(
|
||||
authConfig.enabled
|
||||
? `Auth: enabled (user=${authConfig.username})`
|
||||
? `Auth: enabled (user=${authConfig.username}, cookieSecure=${authConfig.cookieSecureMode})`
|
||||
: "Auth: disabled (set AUTH_PASSWORD to enable)",
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user