From e0e76446db8aac3d0b97e8702ee3c1d301469c04 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Mar 2026 10:32:52 -0700 Subject: [PATCH] feat(encryptid): cross-session logout propagation When a user logs out in one browser, all other sessions are now revoked on their next page load or token refresh. Adds logged_out_at column to users table, server-side revocation checks on verify/refresh endpoints, and a new /api/session/logout endpoint. Co-Authored-By: Claude Opus 4.6 --- src/encryptid/db.ts | 11 ++++++++ src/encryptid/schema.sql | 8 ++++++ src/encryptid/server.ts | 48 ++++++++++++++++++++++++++++++++ src/encryptid/session.ts | 45 +++++++++++++++++++++++++++++- src/encryptid/ui/login-button.ts | 17 +++++++++++ 5 files changed, 128 insertions(+), 1 deletion(-) diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts index 6231a0e..25dba74 100644 --- a/src/encryptid/db.ts +++ b/src/encryptid/db.ts @@ -249,6 +249,17 @@ export async function getUserById(userId: string) { return user || null; } +/** Record a global logout — all JWTs issued before this timestamp are revoked */ +export async function setUserLoggedOutAt(userId: string): Promise { + await sql`UPDATE users SET logged_out_at = NOW() WHERE id = ${userId}`; +} + +/** Get the timestamp of the user's last global logout (null if never) */ +export async function getUserLoggedOutAt(userId: string): Promise { + const [row] = await sql`SELECT logged_out_at FROM users WHERE id = ${userId}`; + return row?.logged_out_at ? new Date(row.logged_out_at) : null; +} + /** Update a user's DID (e.g. upgrading from truncated to proper did:key:z6Mk...) */ export async function updateUserDid(userId: string, newDid: string): Promise { await sql`UPDATE users SET did = ${newDid}, updated_at = NOW() WHERE id = ${userId}`; diff --git a/src/encryptid/schema.sql b/src/encryptid/schema.sql index 84b3261..0be3adf 100644 --- a/src/encryptid/schema.sql +++ b/src/encryptid/schema.sql @@ -548,3 +548,11 @@ ALTER TABLE fund_claims ADD COLUMN IF NOT EXISTS email_enc TEXT; ALTER TABLE fund_claims ADD COLUMN IF NOT EXISTS email_hmac TEXT; ALTER TABLE fund_claims ADD COLUMN IF NOT EXISTS wallet_address_enc TEXT; CREATE INDEX IF NOT EXISTS idx_fund_claims_email_hmac ON fund_claims(email_hmac); + +-- ============================================================================ +-- SESSION REVOCATION (cross-session logout propagation) +-- ============================================================================ + +-- When a user logs out, this timestamp is set. Any JWT issued before this +-- timestamp is considered revoked on verify/refresh. +ALTER TABLE users ADD COLUMN IF NOT EXISTS logged_out_at TIMESTAMPTZ; diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 9718ae9..9fb35a9 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -134,6 +134,8 @@ import { getUserByUPAddress, updateUserDid, migrateSpaceMemberDid, + setUserLoggedOutAt, + getUserLoggedOutAt, } from './db.js'; import { isMailcowConfigured, @@ -816,6 +818,14 @@ app.post('/api/auth/complete', async (c) => { // SESSION ENDPOINTS // ============================================================================ +/** Check if a token was issued before the user's last global logout */ +async function isTokenRevokedByLogout(userId: string, iat: number): Promise { + const loggedOutAt = await getUserLoggedOutAt(userId); + if (!loggedOutAt) return false; + // Token issued before the logout timestamp → revoked + return iat <= Math.floor(loggedOutAt.getTime() / 1000); +} + /** * Verify session token (supports both GET with Authorization header and POST with body) */ @@ -829,6 +839,12 @@ app.get('/api/session/verify', async (c) => { try { const payload = await verify(token, CONFIG.jwtSecret, 'HS256'); + + // Check if session was revoked by a logout from another session + if (await isTokenRevokedByLogout(payload.sub as string, payload.iat as number)) { + return c.json({ valid: false, error: 'Session revoked' }, 401); + } + return c.json({ valid: true, userId: payload.sub, @@ -849,6 +865,11 @@ app.post('/api/session/verify', async (c) => { try { const payload = await verify(token, CONFIG.jwtSecret, 'HS256'); + + if (await isTokenRevokedByLogout(payload.sub as string, payload.iat as number)) { + return c.json({ valid: false, error: 'Session revoked' }); + } + return c.json({ valid: true, claims: payload, @@ -876,6 +897,11 @@ app.post('/api/session/refresh', async (c) => { try { const payload = await verify(token, CONFIG.jwtSecret, { alg: 'HS256', exp: false }); // Allow expired tokens for refresh + // Reject refresh if user logged out globally after this token was issued + if (await isTokenRevokedByLogout(payload.sub as string, payload.iat as number)) { + return c.json({ error: 'Session revoked' }, 401); + } + // Ensure username is present — look up from DB if missing from old token let username = payload.username as string | undefined; if (!username) { @@ -895,6 +921,28 @@ app.post('/api/session/refresh', async (c) => { } }); +/** + * Logout — revoke all sessions for this user (cross-session propagation). + * Other browser sessions will detect this on their next verify/refresh. + */ +app.post('/api/session/logout', async (c) => { + const authHeader = c.req.header('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return c.json({ error: 'No token' }, 401); + } + + const token = authHeader.slice(7); + + try { + // Accept even expired tokens for logout (user should always be able to log out) + const payload = await verify(token, CONFIG.jwtSecret, { alg: 'HS256', exp: false }); + await setUserLoggedOutAt(payload.sub as string); + return c.json({ ok: true }); + } catch { + return c.json({ error: 'Invalid token' }, 401); + } +}); + // ============================================================================ // USER INFO ENDPOINTS // ============================================================================ diff --git a/src/encryptid/session.ts b/src/encryptid/session.ts index f2654a2..a056eb2 100644 --- a/src/encryptid/session.ts +++ b/src/encryptid/session.ts @@ -381,9 +381,18 @@ export class SessionManager { } /** - * Clear session (logout) + * Clear session (logout). + * Notifies the server so all other browser sessions are revoked on their next refresh. */ clearSession(): void { + // Fire-and-forget server logout so other sessions get revoked + if (this.session?.accessToken) { + fetch(`${ENCRYPTID_SERVER}/api/session/logout`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${this.session.accessToken}` }, + }).catch(() => { /* best-effort */ }); + } + this.session = null; if (this.refreshTimer) { @@ -459,6 +468,9 @@ export class SessionManager { this.session = session; this.scheduleRefresh(); console.log('EncryptID: Session restored'); + + // Validate with server — detects logout from another session + this.validateWithServer(); } else { // Session expired, clear it localStorage.removeItem(SESSION_STORAGE_KEY); @@ -469,6 +481,37 @@ export class SessionManager { } } + /** Check with the server that this session hasn't been revoked (e.g. logout from another browser) */ + private async validateWithServer(): Promise { + if (!this.session) return; + + try { + const res = await fetch(`${ENCRYPTID_SERVER}/api/session/verify`, { + headers: { 'Authorization': `Bearer ${this.session.accessToken}` }, + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + if (data.error === 'Session revoked' || res.status === 401) { + console.warn('EncryptID: Session revoked by another session, logging out'); + // Clear locally without hitting the server logout endpoint again + this.session = null; + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + resetLinkedWalletStore(); + try { localStorage.removeItem(SESSION_STORAGE_KEY); } catch { /* ignore */ } + + // Notify the app so UI updates (dispatched on window for global listeners) + window.dispatchEvent(new CustomEvent('encryptid:session-revoked')); + } + } + } catch { + // Network error — don't log out, let the token expire naturally + } + } + private scheduleRefresh(): void { if (!this.session) return; diff --git a/src/encryptid/ui/login-button.ts b/src/encryptid/ui/login-button.ts index 3dd3ce7..a94172a 100644 --- a/src/encryptid/ui/login-button.ts +++ b/src/encryptid/ui/login-button.ts @@ -262,7 +262,20 @@ export class EncryptIDLoginButton extends HTMLElement { this.shadow = this.attachShadow({ mode: 'open' }); } + private _onSessionRevoked = () => { + // Another browser session logged out — clear local state and re-render + const keyManager = getKeyManager(); + keyManager.clear(); + resetVaultManager(); + resetDocBridge(); + this.dispatchEvent(new CustomEvent('logout', { bubbles: true })); + this.render(); + }; + async connectedCallback() { + // Listen for cross-session logout revocation + window.addEventListener('encryptid:session-revoked', this._onSessionRevoked); + // Detect capabilities this.capabilities = await detectCapabilities(); @@ -302,6 +315,10 @@ export class EncryptIDLoginButton extends HTMLElement { return this.hasAttribute('show-user'); } + disconnectedCallback() { + window.removeEventListener('encryptid:session-revoked', this._onSessionRevoked); + } + private render() { const session = getSessionManager(); const isLoggedIn = session.isValid();