[grade=B] fix(auth): use host-only session cookies on custom TLDs
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

Codex: urn:ump:7c22nwh67kot23f6czg5ax7e47hu2r7vowjpz3q6z63o73ti67vq
This commit is contained in:
Krystie
2026-07-24 05:15:08 -07:00
parent 872923dba2
commit 75f835641f
2 changed files with 77 additions and 21 deletions
+13 -21
View File
@@ -227,6 +227,10 @@ module.exports = function configureMiddleware(app, {
ipSessions.delete(getClientIP(req));
}
// Session cookies are intentionally host-only. Browsers reject Domain=.sami
// because .sami is an unregistered custom TLD and therefore treated as a
// public suffix. Cross-subdomain login is handled by the one-time SSO
// handoff below, which mints a separate host-only cookie on each service.
function setSessionCookie(res, durationKey) {
const durationMs = SESSION_DURATIONS[durationKey];
if (!durationMs) return;
@@ -235,9 +239,8 @@ 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}${domainAttr}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax`
`${SESSION_COOKIE_NAME}=${payloadB64}.${sig}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax`
);
}
@@ -268,9 +271,8 @@ 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${domainAttr}; Path=/; HttpOnly; SameSite=Lax`
`${SESSION_COOKIE_NAME}=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax`
);
}
@@ -278,11 +280,12 @@ module.exports = function configureMiddleware(app, {
// + the write-back in this function) caused cross-subdomain SSO breakage when
// Caddy on --network host forwards auth to the container: req.ip arrives as
// 100.121.150.22 (DNS2's tailnet IP) instead of the user's real IP, so the
// IP cache misses even when the cookie is valid. The cookie is signed with a
// persisted HMAC key (loadOrCreateKey()), scoped to .sami via Domain attr,
// HttpOnly + Secure + SameSite=Lax — it's a stronger credential than the IP
// cache. Ref: skill auth-and-monitoring-pitfalls.md "TOTP session validation
// IP-key issue" (FIXED 2026-07-21).
// IP cache misses even when the cookie is valid. The host-only cookie is
// signed with a persisted HMAC key (loadOrCreateKey()). Cross-subdomain
// authentication uses the one-time SSO handoff because browsers reject
// Domain=.sami. HttpOnly + Secure + SameSite=Lax makes it a stronger
// credential than the IP cache. Ref: skill auth-and-monitoring-pitfalls.md
// "TOTP session validation IP-key issue" (FIXED 2026-07-21).
function isSessionValid(req) {
const cookies = parseCookies(req.headers.cookie);
if (verifySessionCookie(cookies[SESSION_COOKIE_NAME])) {
@@ -333,18 +336,7 @@ module.exports = function configureMiddleware(app, {
}
function setHostOnlySessionCookie(res, durationKey) {
const durationMs = SESSION_DURATIONS[durationKey];
if (!durationMs) return;
const maxAge = Math.floor(durationMs / 1000);
const payload = { v: true, exp: Date.now() + durationMs };
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
const key = cryptoUtils.loadOrCreateKey();
const sig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url');
// No Domain attribute — host-only, so it's always accepted regardless of
// the .sami public-suffix issue described above.
res.setHeader('Set-Cookie',
`${SESSION_COOKIE_NAME}=${payloadB64}.${sig}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax`
);
setSessionCookie(res, durationKey);
}
// ── Public routes (bypass TOTP and JWT auth) ──