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:
@@ -48,6 +48,17 @@ 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.
|
* Middleware to set CSRF cookie on requests.
|
||||||
* Preserves existing nonce to avoid invalidating tokens the client has cached.
|
* Preserves existing nonce to avoid invalidating tokens the client has cached.
|
||||||
@@ -68,13 +79,15 @@ function csrfCookieMiddleware(req, res, next) {
|
|||||||
|
|
||||||
// Only set cookie if it's new (avoids unnecessary Set-Cookie headers)
|
// Only set cookie if it's new (avoids unnecessary Set-Cookie headers)
|
||||||
if (!existingNonce) {
|
if (!existingNonce) {
|
||||||
res.cookie(CSRF_COOKIE_NAME, csrfNonce, {
|
const cookieOpts = {
|
||||||
httpOnly: false, // Must be readable by JavaScript for signing
|
httpOnly: false, // Must be readable by JavaScript for signing
|
||||||
secure: req.secure || req.protocol === 'https',
|
secure: req.secure || req.protocol === 'https',
|
||||||
sameSite: 'strict',
|
sameSite: 'strict',
|
||||||
path: '/',
|
path: '/',
|
||||||
maxAge: 365 * 24 * 60 * 60 * 1000 // 1 year (effectively permanent)
|
maxAge: 365 * 24 * 60 * 60 * 1000 // 1 year (effectively permanent)
|
||||||
});
|
};
|
||||||
|
if (cookieDomain) cookieOpts.domain = cookieDomain;
|
||||||
|
res.cookie(CSRF_COOKIE_NAME, csrfNonce, cookieOpts);
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
@@ -89,16 +102,21 @@ function csrfCookieMiddleware(req, res, next) {
|
|||||||
*/
|
*/
|
||||||
function renewCSRFToken(res, secure) {
|
function renewCSRFToken(res, secure) {
|
||||||
const csrfNonce = generateToken();
|
const csrfNonce = generateToken();
|
||||||
res.cookie(CSRF_COOKIE_NAME, csrfNonce, {
|
const cookieOpts = {
|
||||||
httpOnly: false,
|
httpOnly: false,
|
||||||
secure: !!secure,
|
secure: !!secure,
|
||||||
sameSite: 'strict',
|
sameSite: 'strict',
|
||||||
path: '/',
|
path: '/',
|
||||||
maxAge: 365 * 24 * 60 * 60 * 1000
|
maxAge: 365 * 24 * 60 * 60 * 1000
|
||||||
});
|
};
|
||||||
|
if (cookieDomain) cookieOpts.domain = cookieDomain;
|
||||||
|
res.cookie(CSRF_COOKIE_NAME, csrfNonce, cookieOpts);
|
||||||
return signToken(csrfNonce);
|
return signToken(csrfNonce);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { csrfCookieMiddleware, renewCSRFToken };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware to validate CSRF token on state-changing requests
|
* Middleware to validate CSRF token on state-changing requests
|
||||||
* Validates that the token in the cookie matches the token in the header
|
* Validates that the token in the cookie matches the token in the header
|
||||||
@@ -194,6 +212,9 @@ function csrfValidationMiddleware(req, res, next) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default instance (no domain) for backward compatibility with tests
|
||||||
|
const defaultInstance = createCSRFMiddleware();
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
CSRF_TOKEN_LENGTH,
|
CSRF_TOKEN_LENGTH,
|
||||||
CSRF_COOKIE_NAME,
|
CSRF_COOKIE_NAME,
|
||||||
@@ -201,7 +222,9 @@ module.exports = {
|
|||||||
generateToken,
|
generateToken,
|
||||||
signToken,
|
signToken,
|
||||||
parseCookie,
|
parseCookie,
|
||||||
csrfCookieMiddleware,
|
createCSRFMiddleware,
|
||||||
csrfValidationMiddleware,
|
csrfValidationMiddleware,
|
||||||
renewCSRFToken
|
// Default instance exports for backward compat
|
||||||
|
csrfCookieMiddleware: defaultInstance.csrfCookieMiddleware,
|
||||||
|
renewCSRFToken: defaultInstance.renewCSRFToken
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const helmet = require('helmet');
|
|||||||
const compression = require('compression');
|
const compression = require('compression');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const rateLimit = require('express-rate-limit');
|
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 { RATE_LIMITS, LIMITS, APP } = require('./constants');
|
||||||
const { CACHE_CONFIGS, createCache } = require('./cache-config');
|
const { CACHE_CONFIGS, createCache } = require('./cache-config');
|
||||||
|
|
||||||
@@ -75,7 +75,10 @@ module.exports = function configureMiddleware(app, {
|
|||||||
// ── Compress responses (gzip/brotli) ──
|
// ── Compress responses (gzip/brotli) ──
|
||||||
app.use(compression());
|
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(csrfCookieMiddleware);
|
||||||
app.use(csrfValidationMiddleware);
|
app.use(csrfValidationMiddleware);
|
||||||
|
|
||||||
@@ -221,8 +224,9 @@ module.exports = function configureMiddleware(app, {
|
|||||||
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||||
const key = cryptoUtils.loadOrCreateKey();
|
const key = cryptoUtils.loadOrCreateKey();
|
||||||
const sig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url');
|
const sig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url');
|
||||||
|
const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : '';
|
||||||
res.setHeader('Set-Cookie',
|
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) {
|
function clearSessionCookie(res) {
|
||||||
|
const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : '';
|
||||||
res.setHeader('Set-Cookie',
|
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,
|
clearIPSession,
|
||||||
clearSessionCookie,
|
clearSessionCookie,
|
||||||
isSessionValid,
|
isSessionValid,
|
||||||
ipSessions
|
ipSessions,
|
||||||
|
renewCSRFToken
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ module.exports = function(ctx) {
|
|||||||
fetchT: ctx.fetchT,
|
fetchT: ctx.fetchT,
|
||||||
getServiceById: ctx.getServiceById,
|
getServiceById: ctx.getServiceById,
|
||||||
licenseManager: ctx.licenseManager,
|
licenseManager: ctx.licenseManager,
|
||||||
servicesStateManager: ctx.servicesStateManager
|
servicesStateManager: ctx.servicesStateManager,
|
||||||
|
renewCSRFToken: ctx.middlewareResult?.renewCSRFToken
|
||||||
};
|
};
|
||||||
|
|
||||||
const { getAppSession, appSessionCache } = initSessionHandlers(deps);
|
const { getAppSession, appSessionCache } = initSessionHandlers(deps);
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ module.exports = function({ authManager: _authManager, credentialManager: _crede
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
loginUrl = `${baseUrl}login`;
|
loginUrl = `${baseUrl.replace(/\/+$/, '')}/login`;
|
||||||
loginBody = `username=${formEncode(username)}&password=${formEncode(password)}&rememberMe=on`;
|
loginBody = `username=${formEncode(username)}&password=${formEncode(password)}&rememberMe=on`;
|
||||||
extraHeaders['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
extraHeaders['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
break;
|
break;
|
||||||
@@ -168,7 +168,9 @@ module.exports = function({ authManager: _authManager, credentialManager: _crede
|
|||||||
|
|
||||||
const rawCookie = resp.headers.get('set-cookie');
|
const rawCookie = resp.headers.get('set-cookie');
|
||||||
if (rawCookie) {
|
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 });
|
appSessionCache.set(serviceId, { cookies, exp: Date.now() + SESSION_TTL.COOKIE_SESSION });
|
||||||
log.info('auth', 'Auto-login successful (fallback), session cached', { serviceId });
|
log.info('auth', 'Auto-login successful (fallback), session cached', { serviceId });
|
||||||
return cookies;
|
return cookies;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { renewCSRFToken } = require('../../csrf-protection');
|
|
||||||
const { ValidationError, AuthenticationError } = require('../../errors');
|
const { ValidationError, AuthenticationError } = require('../../errors');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,7 +14,7 @@ const { ValidationError, AuthenticationError } = require('../../errors');
|
|||||||
* @param {Object} deps.log - Logger instance
|
* @param {Object} deps.log - Logger instance
|
||||||
* @returns {express.Router}
|
* @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();
|
const router = express.Router();
|
||||||
|
|
||||||
// Ctx shim for backward compatibility
|
// Ctx shim for backward compatibility
|
||||||
|
|||||||
@@ -160,6 +160,9 @@ function assembleContext({
|
|||||||
loadNotificationConfig,
|
loadNotificationConfig,
|
||||||
resyncHealthChecker,
|
resyncHealthChecker,
|
||||||
|
|
||||||
|
// Middleware result (exposes renewCSRFToken etc.)
|
||||||
|
middlewareResult,
|
||||||
|
|
||||||
// File paths
|
// File paths
|
||||||
SERVICES_FILE,
|
SERVICES_FILE,
|
||||||
CONFIG_FILE,
|
CONFIG_FILE,
|
||||||
|
|||||||
@@ -86,7 +86,13 @@ function _httpsFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
|
|||||||
statusText: res.statusMessage,
|
statusText: res.statusMessage,
|
||||||
json: () => Promise.resolve(JSON.parse(data)),
|
json: () => Promise.resolve(JSON.parse(data)),
|
||||||
text: () => Promise.resolve(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,
|
statusText: res.statusMessage,
|
||||||
json: () => Promise.resolve(JSON.parse(data)),
|
json: () => Promise.resolve(JSON.parse(data)),
|
||||||
text: () => Promise.resolve(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]) : [];
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user