Skip to content

Commit 66cfedd

Browse files
committed
fix(auth): enforce session revocation on live sockets and add a sign-out button
Revoking a session only ever mattered on the next request: an open socket kept its JWT and the phone kept driving the show. Sockets are now bound to the session id in their token, checked at handshake and re-checked against the store, and closed with 4001 session-revoked when the row is gone. The UI treats 4001 as 'signed out' and stops reconnecting, and the mobile menu gained a Sign out entry that revokes the session server-side via POST /api/logout.
1 parent aaf71eb commit 66cfedd

15 files changed

Lines changed: 674 additions & 33 deletions

File tree

‎packages/desktop/src/renderer/lib/use-wavegrid.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -292,8 +292,8 @@ export function useProjectUsers({ project, rev }: ProjectScope): {
292292
}
293293

294294
/** Active UI login sessions for a project (who's logged in). Local admin reads
295-
* straight from the shared store; revoke removes the row (the client loses
296-
* access on its next token refresh — sockets are untouched). */
295+
* straight from the shared store; revoke removes the row, which the server
296+
* enforces on the next request and by closing that user's socket. */
297297
export function useSessions({ project, rev }: ProjectScope): {
298298
sessions: SessionInfo[];
299299
refresh: () => Promise<void>;

‎packages/desktop/src/types/ipc.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,7 @@ export interface WavegridApi {
413413
sessions: {
414414
/** Active (non-expired) UI login sessions for a project, newest first. */
415415
list(project: string): Promise<SessionInfo[]>;
416-
/** Revoke a session by id (takes effect on the client's next token refresh). */
416+
/** Revoke a session by id (rejects its next request and closes its socket). */
417417
revoke(project: string, id: string): Promise<SessionInfo[]>;
418418
};
419419
keys: {
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { loadWavegridConfig } from '@wavegrid/layout';
2+
import { openStore } from '@wavegrid/settings';
3+
import { mkdtempSync } from 'fs';
4+
import { tmpdir } from 'os';
5+
import { join } from 'path';
6+
import { WebSocket } from 'ws';
7+
8+
import { startServer, type ServerHandle } from '../src/server';
9+
10+
/**
11+
* Revocation has to bite on the live socket, not just on the next request: an
12+
* operator booting a phone off the show watched it keep painting lasers.
13+
*/
14+
interface Client {
15+
ws: WebSocket;
16+
/** Resolves with the close code once the brain drops us. */
17+
closed: Promise<{ code: number; reason: string }>;
18+
}
19+
20+
/** Every client opened here, so none is left holding the event loop open. */
21+
const opened: WebSocket[] = [];
22+
23+
function connect(port: number, token: string): Promise<Client> {
24+
return new Promise((resolve, reject) => {
25+
const ws = new WebSocket(`ws://127.0.0.1:${port}/?token=${encodeURIComponent(token)}`);
26+
opened.push(ws);
27+
const closed = new Promise<{ code: number; reason: string }>((r) => {
28+
ws.on('close', (code, reason) => r({ code, reason: reason.toString() }));
29+
});
30+
ws.on('open', () => resolve({ ws, closed }));
31+
ws.on('error', reject);
32+
});
33+
}
34+
35+
const wait = (ms: number): Promise<void> => new Promise((r) => setTimeout(() => r(), ms));
36+
37+
/** The close event, or null if the socket is still up after `ms`. */
38+
async function closeWithin(
39+
client: Client,
40+
ms: number
41+
): Promise<{ code: number; reason: string } | null> {
42+
const timeout = wait(ms).then((): null => null);
43+
return Promise.race([client.closed, timeout]);
44+
}
45+
46+
describe('session revocation', () => {
47+
const saved = { ...process.env };
48+
let handle: ServerHandle;
49+
let port: number;
50+
let base: string;
51+
52+
async function login(username: string, password: string): Promise<string> {
53+
const res = await fetch(`${base}/api/login`, {
54+
method: 'POST',
55+
headers: { 'Content-Type': 'application/json' },
56+
body: JSON.stringify({ username, password })
57+
});
58+
const body = await res.json();
59+
expect(body.token).toBeTruthy();
60+
return body.token as string;
61+
}
62+
63+
beforeAll(async () => {
64+
const store = mkdtempSync(join(tmpdir(), 'wg-revoke-store-'));
65+
const state = mkdtempSync(join(tmpdir(), 'wg-revoke-state-'));
66+
process.env.APPSTASH_BASE_DIR = store;
67+
process.env.WAVEGRID_PROJECT = 'demo';
68+
process.env.WG_STATE_DIR = state;
69+
process.env.WG_JWT_SECRET = 'revoke-test-secret';
70+
process.env.WG_SESSION_SWEEP_MS = '50';
71+
delete process.env.WAVEGRID_LAYOUT;
72+
delete process.env.WAVEGRID_MODE;
73+
delete process.env.LIGHT_MAP_CONFIG;
74+
75+
const s = openStore();
76+
s.createProject('demo', {
77+
layout: { preset: 'grid-7x7' },
78+
server: { host: '127.0.0.1', port: 0 }
79+
});
80+
s.setActiveProject('demo');
81+
s.addUser('demo', 'boss', 'bosspw'); // first user → admin
82+
s.addUser('demo', 'alice', 'alicepw');
83+
s.addUser('demo', 'bob', 'bobpw');
84+
85+
handle = startServer(loadWavegridConfig(), { uiDir: null, advertise: false });
86+
await handle.ready;
87+
const addr = handle.server.address();
88+
port = typeof addr === 'object' && addr ? addr.port : 0;
89+
base = `http://127.0.0.1:${port}`;
90+
});
91+
92+
afterAll(() => {
93+
for (const ws of opened) ws.terminate();
94+
handle.stop();
95+
process.env = { ...saved };
96+
});
97+
98+
it('drops the revoked user’s live socket and leaves everyone else connected', async () => {
99+
const [adminToken, aliceToken, bobToken] = await Promise.all([
100+
login('boss', 'bosspw'),
101+
login('alice', 'alicepw'),
102+
login('bob', 'bobpw')
103+
]);
104+
const alice = await connect(port, aliceToken);
105+
const bob = await connect(port, bobToken);
106+
107+
const aliceSession = openStore()
108+
.listSessions('demo')
109+
.find((s) => s.username === 'alice');
110+
expect(aliceSession).toBeDefined();
111+
112+
const res = await fetch(`${base}/api/admin/sessions/${aliceSession!.id}`, {
113+
method: 'DELETE',
114+
headers: { Authorization: `Bearer ${adminToken}` }
115+
});
116+
expect(res.status).toBe(200);
117+
118+
const closed = await closeWithin(alice, 2000);
119+
expect(closed?.code).toBe(4001);
120+
expect(closed?.reason).toBe('session-revoked');
121+
expect(await closeWithin(bob, 200)).toBeNull();
122+
123+
// Her token is finished for HTTP too, and can't open a new socket.
124+
const me = await fetch(`${base}/api/me`, {
125+
headers: { Authorization: `Bearer ${aliceToken}` }
126+
});
127+
expect(me.status).toBe(401);
128+
await expect(connect(port, aliceToken)).rejects.toThrow(/401/);
129+
130+
// Bob is untouched: still authenticated on HTTP, still driving the show.
131+
const bobMe = await fetch(`${base}/api/me`, {
132+
headers: { Authorization: `Bearer ${bobToken}` }
133+
});
134+
expect(bobMe.status).toBe(200);
135+
bob.ws.close();
136+
});
137+
138+
it('drops a socket when the session is revoked out-of-process (desktop app)', async () => {
139+
const token = await login('bob', 'bobpw');
140+
const client = await connect(port, token);
141+
142+
// The desktop app revokes by writing the same store — no HTTP call to hook.
143+
expect(openStore().revokeUserSessions('demo', 'bob')).toBeGreaterThan(0);
144+
145+
const closed = await closeWithin(client, 2000);
146+
expect(closed?.code).toBe(4001);
147+
});
148+
149+
it('signing out ends the session, so the token is dead everywhere', async () => {
150+
const token = await login('alice', 'alicepw');
151+
const client = await connect(port, token);
152+
153+
const res = await fetch(`${base}/api/logout`, {
154+
method: 'POST',
155+
headers: { Authorization: `Bearer ${token}` }
156+
});
157+
expect(res.status).toBe(200);
158+
159+
expect(openStore().listSessions('demo').some((s) => s.username === 'alice')).toBe(false);
160+
expect((await closeWithin(client, 2000))?.code).toBe(4001);
161+
const me = await fetch(`${base}/api/me`, { headers: { Authorization: `Bearer ${token}` } });
162+
expect(me.status).toBe(401);
163+
});
164+
});
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { signJwt } from '../src/jwt';
2+
import {
3+
authenticateSocketToken,
4+
type SessionLookup,
5+
socketsWithRevokedSessions
6+
} from '../src/ws-auth';
7+
8+
/** A store stand-in: the project it serves plus the sessions still alive. */
9+
function lookup(project: string | null, live: string[]): SessionLookup {
10+
const set = new Set(live);
11+
return {
12+
project: () => project,
13+
isSessionLive: (_project, sid) => set.has(sid)
14+
};
15+
}
16+
17+
describe('authenticateSocketToken', () => {
18+
const saved = { ...process.env };
19+
beforeAll(() => {
20+
process.env.WG_JWT_SECRET = 'socket-test-secret';
21+
});
22+
afterAll(() => {
23+
process.env = { ...saved };
24+
});
25+
26+
it('accepts a token whose session is still live', () => {
27+
const token = signJwt('ada', { sid: 'sess-1', role: 'operator', ttlSec: 60 });
28+
expect(authenticateSocketToken(token, lookup('demo', ['sess-1']))).toEqual({
29+
ok: true,
30+
username: 'ada',
31+
sid: 'sess-1'
32+
});
33+
});
34+
35+
it('rejects a signed token whose session was revoked', () => {
36+
const token = signJwt('ada', { sid: 'sess-1', role: 'operator', ttlSec: 60 });
37+
expect(authenticateSocketToken(token, lookup('demo', []))).toEqual({
38+
ok: false,
39+
reason: 'session-revoked'
40+
});
41+
});
42+
43+
it('rejects a session-bound token when no project is active', () => {
44+
const token = signJwt('ada', { sid: 'sess-1', ttlSec: 60 });
45+
expect(authenticateSocketToken(token, lookup(null, ['sess-1']))).toEqual({
46+
ok: false,
47+
reason: 'session-revoked'
48+
});
49+
});
50+
51+
it('rejects a garbled token and a missing one', () => {
52+
expect(authenticateSocketToken('not.a.jwt', lookup('demo', []))).toEqual({
53+
ok: false,
54+
reason: 'invalid-token'
55+
});
56+
expect(authenticateSocketToken(null, lookup('demo', []))).toEqual({
57+
ok: false,
58+
reason: 'no-credentials'
59+
});
60+
});
61+
62+
it('still accepts a legacy token that carries no session id', () => {
63+
const token = signJwt('ada');
64+
expect(authenticateSocketToken(token, lookup('demo', []))).toEqual({
65+
ok: true,
66+
username: 'ada',
67+
sid: null
68+
});
69+
});
70+
});
71+
72+
describe('socketsWithRevokedSessions', () => {
73+
it('picks only the sockets whose session is gone', () => {
74+
const sockets: { name: string; sid: string | null }[] = [
75+
{ name: 'ada', sid: 'sess-ada' },
76+
{ name: 'bob', sid: 'sess-bob' },
77+
{ name: 'receiver', sid: null }
78+
];
79+
const revoked = socketsWithRevokedSessions(sockets, lookup('demo', ['sess-bob']));
80+
expect(revoked.map((s) => s.name)).toEqual(['ada']);
81+
});
82+
83+
it('sweeps every socket sharing the revoked session, reading the store once', () => {
84+
const reads: string[] = [];
85+
const store: SessionLookup = {
86+
project: () => 'demo',
87+
isSessionLive: (_p, sid) => {
88+
reads.push(sid);
89+
return false;
90+
}
91+
};
92+
const sockets = [
93+
{ name: 'phone', sid: 'sess-ada' },
94+
{ name: 'laptop', sid: 'sess-ada' }
95+
];
96+
expect(socketsWithRevokedSessions(sockets, store).map((s) => s.name)).toEqual([
97+
'phone',
98+
'laptop'
99+
]);
100+
expect(reads).toEqual(['sess-ada']);
101+
});
102+
103+
it('leaves receiver sockets alone even with no active project', () => {
104+
const sockets: { name: string; sid: string | null }[] = [{ name: 'receiver', sid: null }];
105+
expect(socketsWithRevokedSessions(sockets, lookup(null, []))).toEqual([]);
106+
});
107+
});

‎packages/server/src/http-app.ts‎

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ import { signJwt, verifyJwt } from './jwt';
1515
export interface HttpAppOptions {
1616
/** Directory of the built UI (Vite `dist`). Static serving is skipped if unset/missing. */
1717
uiDir?: string | null;
18+
/**
19+
* Called after sessions were revoked through the API, so the embedding server
20+
* can drop the sockets those sessions are still holding open instead of
21+
* waiting for its next sweep.
22+
*/
23+
onSessionsRevoked?: () => void;
1824
}
1925

2026
const MIME: Record<string, string> = {
@@ -219,6 +225,7 @@ function normalizeLightMap(
219225
*/
220226
export function createHttpApp(resolved: ResolvedConfig, opts: HttpAppOptions = {}) {
221227
const layout = resolved.layout;
228+
const sessionsRevoked = () => opts.onSessionsRevoked?.();
222229
const uiDir = opts.uiDir && fs.existsSync(opts.uiDir) ? opts.uiDir : null;
223230
const dims = { numCannons: layout.count, gridColumns: layout.cols };
224231

@@ -307,8 +314,8 @@ export function createHttpApp(resolved: ResolvedConfig, opts: HttpAppOptions = {
307314
return;
308315
}
309316
// Record a cheap server-side session and bind the JWT to it, so admins
310-
// can see who's logged in and revoke on the next refresh. Sockets are
311-
// untouched — the token remains the only credential they check.
317+
// can see who's logged in and revoke it. The socket opened with this
318+
// token is bound to the same session and dies with it.
312319
const session = store.createSession(project, {
313320
username: user.username,
314321
role: user.role,
@@ -331,6 +338,22 @@ export function createHttpApp(resolved: ResolvedConfig, opts: HttpAppOptions = {
331338
return;
332339
}
333340

341+
// ── POST /api/logout — end my own session ───────────────────────
342+
// Signing out has to reach the server: the token alone stays valid until it
343+
// expires, and the socket it opened would keep running as that user.
344+
if (pathname === '/api/logout' && method === 'POST') {
345+
const token = bearerToken(req, url);
346+
const payload = token ? verifyJwt(token) : null;
347+
const project = activeProject();
348+
if (payload?.sid && project && openStore().revokeSession(project, payload.sid)) {
349+
sessionsRevoked();
350+
}
351+
// Never an error: a client that is throwing its token away is done either
352+
// way, and telling it otherwise would only strand it signed in.
353+
sendJson(res, 200, { ok: true });
354+
return;
355+
}
356+
334357
// ── GET /api/me — who am I (any valid token) ────────────────────
335358
if (pathname === '/api/me' && method === 'GET') {
336359
const token = bearerToken(req, url);
@@ -371,6 +394,7 @@ export function createHttpApp(resolved: ResolvedConfig, opts: HttpAppOptions = {
371394
const caller = requireAdmin(req, url, res);
372395
if (!caller) return;
373396
const removed = openStore().revokeSession(caller.project, decodeURIComponent(m[1]));
397+
if (removed) sessionsRevoked();
374398
sendJson(res, removed ? 200 : 404, { ok: removed });
375399
return;
376400
}
@@ -440,7 +464,10 @@ export function createHttpApp(resolved: ResolvedConfig, opts: HttpAppOptions = {
440464
const store = openStore();
441465
try {
442466
const removed = store.removeUser(caller.project, username);
443-
if (removed) store.revokeUserSessions(caller.project, username);
467+
if (removed) {
468+
store.revokeUserSessions(caller.project, username);
469+
sessionsRevoked();
470+
}
444471
sendJson(res, removed ? 200 : 404, { ok: removed });
445472
} catch (e) {
446473
sendJson(res, 400, { ok: false, error: (e as Error).message });

0 commit comments

Comments
 (0)