fix(auth): token-based handoff for cross-subdomain SSO

Domain=.sami cookies are silently rejected by real browsers - .sami is an
unregistered custom TLD, so browsers treat sami itself as the effective
public suffix and refuse to set a cookie scoped to it (the same rule that
stops a site from setting a supercookie for all of .com). Confirmed via
curl verbose (cookie dropped, domain must not set cookies for sami) and
via the Firefox console on the actual device (Cookie rejected for invalid
domain) for the same cookie. The session cookie set on status.sami after
TOTP verify could never reach plex.sami/jellyfin.sami/emby.sami/chat.sami
no matter how the cookie itself was built - prior fixes tonight left this
mechanism untouched, which is why the loop persisted.

Fix: /totp/verify mints a short-lived (60s) single-use opaque token. The
status.sami frontend appends it to the redirect URL when bouncing the
user back to a gated service. That services login page exchanges the
token via the new public GET /api/v1/auth/sso-exchange for a host-only
session cookie (no Domain attribute - always accepted). isSessionValid
only checks the cookies HMAC signature, never its Domain, so the host-only
cookie validates identically to the cross-domain one on every existing
check with zero changes to that logic.
This commit is contained in:
Krystie
2026-07-24 05:15:03 -07:00
parent f42e761e52
commit 10f2bf707b
7 changed files with 170 additions and 43 deletions
+14 -2
View File
@@ -220,8 +220,17 @@ const SETUP_WINDOW_MS = 60 * 60 * 1000;
// Rotate CSRF token for the new session
const newCsrfToken = renewCSRFToken(res, req.secure || req.protocol === 'https');
// Cross-subdomain SSO handoff token (see middleware.js "Cross-subdomain
// SSO token handoff" for why): the Domain=.sami cookie set above is
// silently dropped by real browsers on any OTHER *.sami subdomain, so
// status.sami's login-page frontend appends this token to the redirect
// URL when bouncing the user back to a gated service. That service's
// login page exchanges it via /auth/sso-exchange for its own host-only
// session cookie. Single-use, 60s TTL — see ctx.session.createHandoffToken.
const ssoToken = ctx.session.createHandoffToken();
log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size });
ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken });
ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken, ssoToken });
}, 'totp-verify'));
// Check session validity (used by Caddy forward_auth)
@@ -243,7 +252,10 @@ const SETUP_WINDOW_MS = 60 * 60 * 1000;
const valid = ctx.session.isValid(req);
log.debug('auth', 'Session check', { ip: ctx.session.getClientIP(req), valid, sessions: ctx.session.ipSessions.size });
if (valid) {
return res.status(200).json({ authenticated: true });
// Response contract: { success: true, authenticated: true } — login-page
// consumer in /api/v1/auth/login-page reads `if(!st.success||!st.authenticated)`
// and would otherwise redirect valid sessions to status.sami in a TOTP loop.
return ok(res, { authenticated: true });
}
throw new AuthenticationError('Session expired or invalid');