diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index b4aef98..121539c 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -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() {