fix(auth): wire cross-session logout in rstack-identity + encryptid profile

rstack-identity is the actual sign-out component used in production.
clearSession() now calls /api/session/logout, and connectedCallback
validates the session with the server to detect revocation. Also
updated the auth.rspace.online profile page handleLogout().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 10:46:44 -07:00
parent c2b5820f8a
commit 4722aca065
2 changed files with 44 additions and 0 deletions

View File

@ -157,6 +157,20 @@ export function getSession(): SessionState | null {
} }
export function clearSession(): void { export function clearSession(): void {
// Notify server so all other browser sessions get revoked
try {
const stored = localStorage.getItem(SESSION_KEY);
if (stored) {
const session = JSON.parse(stored) as SessionState;
if (session.accessToken) {
fetch(`${ENCRYPTID_URL}/api/session/logout`, {
method: "POST",
headers: { Authorization: `Bearer ${session.accessToken}` },
}).catch(() => { /* best-effort */ });
}
}
} catch { /* ignore */ }
localStorage.removeItem(SESSION_KEY); localStorage.removeItem(SESSION_KEY);
localStorage.removeItem("rspace-username"); localStorage.removeItem("rspace-username");
_removeSessionCookie(); _removeSessionCookie();
@ -352,6 +366,9 @@ export class RStackIdentity extends HTMLElement {
autoProvisionSpace(session.accessToken); autoProvisionSpace(session.accessToken);
} }
// Validate session with server — detects logout from another browser session
this.#validateSessionWithServer();
// Propagate login/logout across tabs via storage events // Propagate login/logout across tabs via storage events
window.addEventListener("storage", this.#onStorageChange); window.addEventListener("storage", this.#onStorageChange);
} }
@ -360,6 +377,25 @@ export class RStackIdentity extends HTMLElement {
window.removeEventListener("storage", this.#onStorageChange); window.removeEventListener("storage", this.#onStorageChange);
} }
async #validateSessionWithServer() {
const session = getSession();
if (!session?.accessToken) return;
try {
const res = await fetch(`${ENCRYPTID_URL}/api/session/verify`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
});
if (!res.ok) {
// Session revoked — clear locally and re-render
localStorage.removeItem(SESSION_KEY);
localStorage.removeItem("rspace-username");
_removeSessionCookie();
resetDocBridge();
this.#render();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
}
} catch { /* network error — let token expire naturally */ }
}
#onStorageChange = (e: StorageEvent) => { #onStorageChange = (e: StorageEvent) => {
if (e.key === "encryptid_session" || e.key === PERSONAS_KEY) { if (e.key === "encryptid_session" || e.key === PERSONAS_KEY) {
this.#render(); this.#render();

View File

@ -7958,6 +7958,14 @@ app.get('/', (c) => {
} }
window.handleLogout = () => { window.handleLogout = () => {
// Notify server so other browser sessions are revoked
const token = localStorage.getItem(TOKEN_KEY);
if (token) {
fetch('/api/session/logout', {
method: 'POST',
headers: { Authorization: 'Bearer ' + token },
}).catch(() => {});
}
localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_KEY);
document.getElementById('auth-form').style.display = 'block'; document.getElementById('auth-form').style.display = 'block';
document.getElementById('profile').style.display = 'none'; document.getElementById('profile').style.display = 'none';