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:
parent
5408eb0376
commit
ef1d93d7e9
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue