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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 10:32:52 -07:00
parent f70972cfb7
commit e0e76446db
5 changed files with 128 additions and 1 deletions

View File

@ -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<void> {
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<Date | null> {
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<void> {
await sql`UPDATE users SET did = ${newDid}, updated_at = NOW() WHERE id = ${userId}`;

View File

@ -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;

View File

@ -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<boolean> {
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
// ============================================================================

View File

@ -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<void> {
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;

View File

@ -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();