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;
|
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...) */
|
/** 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> {
|
export async function updateUserDid(userId: string, newDid: string): Promise<void> {
|
||||||
await sql`UPDATE users SET did = ${newDid}, updated_at = NOW() WHERE id = ${userId}`;
|
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 email_hmac TEXT;
|
||||||
ALTER TABLE fund_claims ADD COLUMN IF NOT EXISTS wallet_address_enc 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);
|
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,
|
getUserByUPAddress,
|
||||||
updateUserDid,
|
updateUserDid,
|
||||||
migrateSpaceMemberDid,
|
migrateSpaceMemberDid,
|
||||||
|
setUserLoggedOutAt,
|
||||||
|
getUserLoggedOutAt,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import {
|
import {
|
||||||
isMailcowConfigured,
|
isMailcowConfigured,
|
||||||
|
|
@ -816,6 +818,14 @@ app.post('/api/auth/complete', async (c) => {
|
||||||
// SESSION ENDPOINTS
|
// 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)
|
* 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 {
|
try {
|
||||||
const payload = await verify(token, CONFIG.jwtSecret, 'HS256');
|
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({
|
return c.json({
|
||||||
valid: true,
|
valid: true,
|
||||||
userId: payload.sub,
|
userId: payload.sub,
|
||||||
|
|
@ -849,6 +865,11 @@ app.post('/api/session/verify', async (c) => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await verify(token, CONFIG.jwtSecret, 'HS256');
|
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({
|
return c.json({
|
||||||
valid: true,
|
valid: true,
|
||||||
claims: payload,
|
claims: payload,
|
||||||
|
|
@ -876,6 +897,11 @@ app.post('/api/session/refresh', async (c) => {
|
||||||
try {
|
try {
|
||||||
const payload = await verify(token, CONFIG.jwtSecret, { alg: 'HS256', exp: false }); // Allow expired tokens for refresh
|
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
|
// Ensure username is present — look up from DB if missing from old token
|
||||||
let username = payload.username as string | undefined;
|
let username = payload.username as string | undefined;
|
||||||
if (!username) {
|
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
|
// 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 {
|
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;
|
this.session = null;
|
||||||
|
|
||||||
if (this.refreshTimer) {
|
if (this.refreshTimer) {
|
||||||
|
|
@ -459,6 +468,9 @@ export class SessionManager {
|
||||||
this.session = session;
|
this.session = session;
|
||||||
this.scheduleRefresh();
|
this.scheduleRefresh();
|
||||||
console.log('EncryptID: Session restored');
|
console.log('EncryptID: Session restored');
|
||||||
|
|
||||||
|
// Validate with server — detects logout from another session
|
||||||
|
this.validateWithServer();
|
||||||
} else {
|
} else {
|
||||||
// Session expired, clear it
|
// Session expired, clear it
|
||||||
localStorage.removeItem(SESSION_STORAGE_KEY);
|
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 {
|
private scheduleRefresh(): void {
|
||||||
if (!this.session) return;
|
if (!this.session) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -262,7 +262,20 @@ export class EncryptIDLoginButton extends HTMLElement {
|
||||||
this.shadow = this.attachShadow({ mode: 'open' });
|
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() {
|
async connectedCallback() {
|
||||||
|
// Listen for cross-session logout revocation
|
||||||
|
window.addEventListener('encryptid:session-revoked', this._onSessionRevoked);
|
||||||
|
|
||||||
// Detect capabilities
|
// Detect capabilities
|
||||||
this.capabilities = await detectCapabilities();
|
this.capabilities = await detectCapabilities();
|
||||||
|
|
||||||
|
|
@ -302,6 +315,10 @@ export class EncryptIDLoginButton extends HTMLElement {
|
||||||
return this.hasAttribute('show-user');
|
return this.hasAttribute('show-user');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
window.removeEventListener('encryptid:session-revoked', this._onSessionRevoked);
|
||||||
|
}
|
||||||
|
|
||||||
private render() {
|
private render() {
|
||||||
const session = getSessionManager();
|
const session = getSessionManager();
|
||||||
const isLoggedIn = session.isValid();
|
const isLoggedIn = session.isValid();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue