fix: cross-subdomain SSO auto-login for *arr services

- Set Domain=.sami on session + CSRF cookies so browsers send them to all subdomains
- This fixes Caddy forward_auth returning 401 for radarr/sonarr/prowlarr
- Fix login URL concatenation bug (radarr.samilogin -> radarr.sami/login)
- Fix getSetCookie() missing from _httpsFetch/_httpFetch response objects
- Fix array/string handling for set-cookie header in session-handlers fallback
- Refactor csrf-protection to createCSRFMiddleware() factory with cookieDomain support
- Pass renewCSRFToken through middleware deps chain to TOTP route
This commit is contained in:
Coderbot
2026-05-23 16:15:56 -07:00
parent fe0f52ce17
commit 445da9f5fc
7 changed files with 101 additions and 55 deletions
+33 -10
View File
@@ -49,13 +49,24 @@ function parseCookie(cookieHeader) {
}
/**
* Create CSRF middleware with cookie domain support.
* When a TLD (e.g. ".sami") is provided, cookies are set with Domain=.sami
* so they are shared across all subdomains for forward_auth SSO.
* @param {Object} [options]
* @param {string} [options.cookieDomain] - e.g. ".sami" to share cookies across subdomains
* @returns {{ csrfCookieMiddleware: Function, renewCSRFToken: Function }}
*/
function createCSRFMiddleware(options = {}) {
const { cookieDomain } = options;
/**
* Middleware to set CSRF cookie on requests.
* Preserves existing nonce to avoid invalidating tokens the client has cached.
* New nonce is generated only on first visit (no cookie) or after TOTP login
* (which calls renewCSRFToken). If TOTP is disabled, the nonce is set once
* and never changes.
*/
function csrfCookieMiddleware(req, res, next) {
function csrfCookieMiddleware(req, res, next) {
const cookies = parseCookie(req.headers.cookie);
const existingNonce = cookies[CSRF_COOKIE_NAME];
@@ -68,35 +79,42 @@ function csrfCookieMiddleware(req, res, next) {
// Only set cookie if it's new (avoids unnecessary Set-Cookie headers)
if (!existingNonce) {
res.cookie(CSRF_COOKIE_NAME, csrfNonce, {
const cookieOpts = {
httpOnly: false, // Must be readable by JavaScript for signing
secure: req.secure || req.protocol === 'https',
sameSite: 'strict',
path: '/',
maxAge: 365 * 24 * 60 * 60 * 1000 // 1 year (effectively permanent)
});
};
if (cookieDomain) cookieOpts.domain = cookieDomain;
res.cookie(CSRF_COOKIE_NAME, csrfNonce, cookieOpts);
}
next();
}
}
/**
/**
* Generate a fresh CSRF nonce and set it on the response.
* Called after TOTP login to rotate the token for the new session.
* @param {Object} res - Express response object
* @param {boolean} secure - Whether to set Secure flag on cookie
* @returns {string} The new CSRF signed token
*/
function renewCSRFToken(res, secure) {
function renewCSRFToken(res, secure) {
const csrfNonce = generateToken();
res.cookie(CSRF_COOKIE_NAME, csrfNonce, {
const cookieOpts = {
httpOnly: false,
secure: !!secure,
sameSite: 'strict',
path: '/',
maxAge: 365 * 24 * 60 * 60 * 1000
});
};
if (cookieDomain) cookieOpts.domain = cookieDomain;
res.cookie(CSRF_COOKIE_NAME, csrfNonce, cookieOpts);
return signToken(csrfNonce);
}
return { csrfCookieMiddleware, renewCSRFToken };
}
/**
@@ -194,6 +212,9 @@ function csrfValidationMiddleware(req, res, next) {
}
}
// Default instance (no domain) for backward compatibility with tests
const defaultInstance = createCSRFMiddleware();
module.exports = {
CSRF_TOKEN_LENGTH,
CSRF_COOKIE_NAME,
@@ -201,7 +222,9 @@ module.exports = {
generateToken,
signToken,
parseCookie,
csrfCookieMiddleware,
createCSRFMiddleware,
csrfValidationMiddleware,
renewCSRFToken
// Default instance exports for backward compat
csrfCookieMiddleware: defaultInstance.csrfCookieMiddleware,
renewCSRFToken: defaultInstance.renewCSRFToken
};
+11 -5
View File
@@ -13,7 +13,7 @@ const helmet = require('helmet');
const compression = require('compression');
const crypto = require('crypto');
const rateLimit = require('express-rate-limit');
const { csrfCookieMiddleware, csrfValidationMiddleware, CSRF_HEADER_NAME } = require('./csrf-protection');
const { createCSRFMiddleware, csrfValidationMiddleware, CSRF_HEADER_NAME } = require('./csrf-protection');
const { RATE_LIMITS, LIMITS, APP } = require('./constants');
const { CACHE_CONFIGS, createCache } = require('./cache-config');
@@ -75,7 +75,10 @@ module.exports = function configureMiddleware(app, {
// ── Compress responses (gzip/brotli) ──
app.use(compression());
// ── CSRF Protection ──
// ── CSRF protection (cookie domain set to TLD for cross-subdomain SSO) ──
const { csrfCookieMiddleware, renewCSRFToken } = createCSRFMiddleware({
cookieDomain: siteConfig.tld || undefined
});
app.use(csrfCookieMiddleware);
app.use(csrfValidationMiddleware);
@@ -221,8 +224,9 @@ module.exports = function configureMiddleware(app, {
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
const key = cryptoUtils.loadOrCreateKey();
const sig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url');
const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : '';
res.setHeader('Set-Cookie',
`${SESSION_COOKIE_NAME}=${payloadB64}.${sig}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax`
`${SESSION_COOKIE_NAME}=${payloadB64}.${sig}${domainAttr}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax`
);
}
@@ -253,8 +257,9 @@ module.exports = function configureMiddleware(app, {
}
function clearSessionCookie(res) {
const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : '';
res.setHeader('Set-Cookie',
`${SESSION_COOKIE_NAME}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax`
`${SESSION_COOKIE_NAME}=; Max-Age=0${domainAttr}; Path=/; HttpOnly; SameSite=Lax`
);
}
@@ -428,6 +433,7 @@ module.exports = function configureMiddleware(app, {
clearIPSession,
clearSessionCookie,
isSessionValid,
ipSessions
ipSessions,
renewCSRFToken
};
};
+2 -1
View File
@@ -27,7 +27,8 @@ module.exports = function(ctx) {
fetchT: ctx.fetchT,
getServiceById: ctx.getServiceById,
licenseManager: ctx.licenseManager,
servicesStateManager: ctx.servicesStateManager
servicesStateManager: ctx.servicesStateManager,
renewCSRFToken: ctx.middlewareResult?.renewCSRFToken
};
const { getAppSession, appSessionCache } = initSessionHandlers(deps);
@@ -121,7 +121,7 @@ module.exports = function({ authManager: _authManager, credentialManager: _crede
return null;
}
default:
loginUrl = `${baseUrl}login`;
loginUrl = `${baseUrl.replace(/\/+$/, '')}/login`;
loginBody = `username=${formEncode(username)}&password=${formEncode(password)}&rememberMe=on`;
extraHeaders['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
break;
@@ -168,7 +168,9 @@ module.exports = function({ authManager: _authManager, credentialManager: _crede
const rawCookie = resp.headers.get('set-cookie');
if (rawCookie) {
const cookies = rawCookie.split(/,(?=[^ ])/).map(c => c.split(';')[0].trim()).join('; ');
// headers.get('set-cookie') may return an array (Node http) or string
const cookieStr = Array.isArray(rawCookie) ? rawCookie.join('; ') : rawCookie;
const cookies = cookieStr.split(/,(?=[^ ])/).map(c => c.split(';')[0].trim()).join('; ');
appSessionCache.set(serviceId, { cookies, exp: Date.now() + SESSION_TTL.COOKIE_SESSION });
log.info('auth', 'Auto-login successful (fallback), session cached', { serviceId });
return cookies;
+1 -2
View File
@@ -1,5 +1,4 @@
const express = require('express');
const { renewCSRFToken } = require('../../csrf-protection');
const { ValidationError, AuthenticationError } = require('../../errors');
/**
@@ -15,7 +14,7 @@ const { ValidationError, AuthenticationError } = require('../../errors');
* @param {Object} deps.log - Logger instance
* @returns {express.Router}
*/
module.exports = function({ authManager, credentialManager, totpConfig, saveTotpConfig, session, asyncHandler, errorResponse, log }) {
module.exports = function({ authManager, credentialManager, totpConfig, saveTotpConfig, session, asyncHandler, errorResponse, log, renewCSRFToken }) {
const router = express.Router();
// Ctx shim for backward compatibility
+3
View File
@@ -160,6 +160,9 @@ function assembleContext({
loadNotificationConfig,
resyncHealthChecker,
// Middleware result (exposes renewCSRFToken etc.)
middlewareResult,
// File paths
SERVICES_FILE,
CONFIG_FILE,
+14 -2
View File
@@ -86,7 +86,13 @@ function _httpsFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
statusText: res.statusMessage,
json: () => Promise.resolve(JSON.parse(data)),
text: () => Promise.resolve(data),
headers: { get: (k) => res.headers[k.toLowerCase()] },
headers: {
get: (k) => res.headers[k.toLowerCase()],
getSetCookie: () => {
const sc = res.headers['set-cookie'];
return sc ? (Array.isArray(sc) ? sc : [sc]) : [];
}
},
});
});
});
@@ -142,7 +148,13 @@ function _httpFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
statusText: res.statusMessage,
json: () => Promise.resolve(JSON.parse(data)),
text: () => Promise.resolve(data),
headers: { get: (k) => res.headers[k.toLowerCase()] },
headers: {
get: (k) => res.headers[k.toLowerCase()],
getSetCookie: () => {
const sc = res.headers['set-cookie'];
return sc ? (Array.isArray(sc) ? sc : [sc]) : [];
}
},
});
});
});