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:
parent
f70972cfb7
commit
e0e76446db
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue