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
|
# 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
@@ -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
@@ -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)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user