feat(encryptid): persist login across subdomains via cross-domain cookie

EncryptID sessions were lost when navigating between rspace.online
subdomains (e.g. demo→cca) because localStorage is per-origin. Now
stores a domain-wide cookie (eid_token, domain=.rspace.online, 30 days)
alongside localStorage. On new subdomain visits, the cookie is synced
to localStorage at module load time. Expired tokens are auto-refreshed
via the server before being discarded. Sign-out clears both stores.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-01 14:10:36 -08:00
parent 5408eb0376
commit ef1d93d7e9
1 changed files with 128 additions and 17 deletions

View File

@ -10,6 +10,8 @@ import { rspaceNavUrl, getCurrentModule } from "../url-helpers";
const SESSION_KEY = "encryptid_session";
const ENCRYPTID_URL = "https://auth.rspace.online";
const COOKIE_NAME = "eid_token";
const COOKIE_MAX_AGE = 30 * 24 * 60 * 60; // 30 days (matches server sessionDuration)
interface SessionState {
accessToken: string;
@ -25,19 +27,101 @@ interface SessionState {
};
}
// ── Cross-subdomain cookie helpers ──
function _isRspace(): boolean {
return window.location.hostname === "rspace.online" || window.location.hostname.endsWith(".rspace.online");
}
function _setSessionCookie(token: string): void {
try {
const domain = _isRspace() ? "; domain=.rspace.online" : "";
const secure = window.location.protocol === "https:" ? "; Secure" : "";
document.cookie = `${COOKIE_NAME}=${encodeURIComponent(token)}; path=/${domain}; max-age=${COOKIE_MAX_AGE}; SameSite=Lax${secure}`;
} catch { /* ignore */ }
}
function _getSessionCookie(): string | null {
try {
const match = document.cookie.match(new RegExp(`(?:^|; )${COOKIE_NAME}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
} catch { return null; }
}
function _removeSessionCookie(): void {
try {
const domain = _isRspace() ? "; domain=.rspace.online" : "";
const secure = window.location.protocol === "https:" ? "; Secure" : "";
document.cookie = `${COOKIE_NAME}=; path=/${domain}; max-age=0; SameSite=Lax${secure}`;
} catch { /* ignore */ }
}
// ── Early cookie→localStorage sync ──
// Runs at module load time so code that directly reads localStorage
// (e.g. folk-canvas WebSocket auth, sync.ts, shell inline scripts)
// sees the session even on a new subdomain.
(function _syncCookieToLocalStorage() {
try {
if (localStorage.getItem(SESSION_KEY)) return; // already have it
const cookieToken = _getSessionCookie();
if (!cookieToken) return;
const payload = parseJWT(cookieToken);
if (!payload.exp || Math.floor(Date.now() / 1000) >= (payload.exp as number)) return;
const session: SessionState = {
accessToken: cookieToken,
claims: {
sub: (payload.sub as string) || "",
exp: (payload.exp as number) || 0,
username: (payload.username as string) || "",
did: (payload.did as string) || "",
eid: (payload.eid as any) || { authLevel: 1, capabilities: { encrypt: true, sign: true, wallet: false } },
},
};
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
if (session.claims.username) localStorage.setItem("rspace-username", session.claims.username);
} catch { /* ignore */ }
})();
// ── Session helpers (exported for use by other code) ──
export function getSession(): SessionState | null {
try {
// 1. Try localStorage first (fast path, same origin)
const stored = localStorage.getItem(SESSION_KEY);
if (!stored) return null;
const session = JSON.parse(stored) as SessionState;
if (Math.floor(Date.now() / 1000) >= session.claims.exp) {
if (stored) {
const session = JSON.parse(stored) as SessionState;
if (Math.floor(Date.now() / 1000) < session.claims.exp) {
return session;
}
// Expired in localStorage — clear it but don't give up yet
localStorage.removeItem(SESSION_KEY);
localStorage.removeItem("rspace-username");
return null;
}
return session;
// 2. Fall back to cross-subdomain cookie (handles navigation between subdomains)
const cookieToken = _getSessionCookie();
if (cookieToken) {
const payload = parseJWT(cookieToken);
if (payload.exp && Math.floor(Date.now() / 1000) < (payload.exp as number)) {
// Valid token in cookie — restore to localStorage for fast access
const session: SessionState = {
accessToken: cookieToken,
claims: {
sub: (payload.sub as string) || "",
exp: (payload.exp as number) || 0,
username: (payload.username as string) || "",
did: (payload.did as string) || "",
eid: (payload.eid as any) || { authLevel: 1, capabilities: { encrypt: true, sign: true, wallet: false } },
},
};
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
if (session.claims.username) localStorage.setItem("rspace-username", session.claims.username);
return session;
}
// Cookie token expired too — don't clear it yet; #refreshIfNeeded will try server refresh
}
return null;
} catch {
return null;
}
@ -46,6 +130,7 @@ export function getSession(): SessionState | null {
export function clearSession(): void {
localStorage.removeItem(SESSION_KEY);
localStorage.removeItem("rspace-username");
_removeSessionCookie();
}
export function isAuthenticated(): boolean {
@ -108,6 +193,7 @@ function storeSession(token: string, username: string, did: string): void {
};
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
if (username) localStorage.setItem("rspace-username", username);
_setSessionCookie(token);
}
// ── Auto-space resolution after auth ──
@ -196,23 +282,48 @@ export class RStackIdentity extends HTMLElement {
}
async #refreshIfNeeded() {
const session = getSession();
if (!session) return;
const now = Math.floor(Date.now() / 1000);
const remaining = session.claims.exp - now;
if (remaining >= 7 * 24 * 60 * 60) return; // more than 7 days left, skip
let session = getSession();
let token = session?.accessToken ?? null;
let username = session?.claims.username || "";
let did = session?.claims.did || "";
// If no valid session in localStorage/cookie, check for an expired cookie token
// we can try to refresh (server accepts expired tokens for refresh)
if (!session) {
const cookieToken = _getSessionCookie();
if (!cookieToken) return; // no token at all — truly logged out
const payload = parseJWT(cookieToken);
token = cookieToken;
username = (payload.username as string) || "";
did = (payload.did as string) || "";
}
// If session is valid with >7 days remaining, no refresh needed
if (session) {
const remaining = session.claims.exp - Math.floor(Date.now() / 1000);
if (remaining >= 7 * 24 * 60 * 60) return;
}
// Token is expired or nearing expiry — try server refresh
try {
const res = await fetch(`${ENCRYPTID_URL}/api/session/refresh`, {
method: "POST",
headers: { Authorization: `Bearer ${session.accessToken}` },
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return;
const { token } = await res.json();
if (token) {
storeSession(token, session.claims.username || "", session.claims.did || "");
this.#render();
if (!res.ok) {
// Server rejected — token is truly dead, clean up
if (!session) { _removeSessionCookie(); }
return;
}
} catch { /* silently ignore user keeps current token */ }
const { token: newToken } = await res.json();
if (newToken) {
const payload = parseJWT(newToken);
storeSession(newToken, (payload.username as string) || username, (payload.did as string) || did);
this.#render();
this.#startNotifPolling();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
}
} catch { /* offline — keep whatever we have */ }
}
#startNotifPolling() {