Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions packages/desktop/__tests__/main-externals.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readFileSync } from 'fs';
import { readdirSync, readFileSync } from 'fs';
import { join } from 'path';

/**
Expand All @@ -16,12 +16,13 @@ const DESKTOP = join(__dirname, '..');
function mainImports(): string[] {
// Read the source rather than importing it: this must reflect what Rollup
// sees, not what a test bundler resolves.
// Every main-process source, discovered rather than listed — a new file that
// imports a @wavegrid package is exactly the case this guards.
const files = [
'src/main.ts',
'src/main/brain.ts',
'src/main/doctor.ts',
'src/main/ipc.ts',
'src/main/network.ts'
...readdirSync(join(DESKTOP, 'src/main'))
.filter((f) => f.endsWith('.ts'))
.map((f) => `src/main/${f}`)
];
const found = new Set<string>();
for (const file of files) {
Expand Down
49 changes: 49 additions & 0 deletions packages/desktop/__tests__/operator-session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { verifyJwt } from '@wavegrid/server';
import { openStore } from '@wavegrid/settings';

import { embeddedUrl, operatorToken } from '../src/main/operator-session';

const PROJECT = 'desk-auth';

beforeAll(() => {
const store = openStore();
store.createProject(PROJECT, { preset: 'ring-6' });
store.generateSecrets(PROJECT);
});

describe('desktop operator session', () => {
it('mints a token the server accepts, bound to a revocable session', () => {
const store = openStore();
store.addUser(PROJECT, 'dan', 'hunter2', 'admin');

const token = operatorToken(PROJECT);
expect(token).not.toBeNull();

const payload = verifyJwt(token!);
expect(payload?.sub).toBe('dan');
expect(payload?.role).toBe('admin');
// Visible in Access → Sessions, so it can be revoked like any other login.
const session = store.listSessions(PROJECT).find((s) => s.id === payload?.sid);
expect(session?.userAgent).toContain('wavegrid-desktop');
});

it('prefers an admin over an operator account', () => {
const store = openStore();
store.addUser(PROJECT, 'guest-op', 'hunter2', 'operator');
expect(verifyJwt(operatorToken(PROJECT)!)?.sub).toBe('dan');
});

it('falls back to the login screen when the project has no accounts', () => {
const store = openStore();
store.createProject('no-users', { preset: 'ring-6' });
store.generateSecrets('no-users');
expect(operatorToken('no-users')).toBeNull();
expect(embeddedUrl('http://127.0.0.1:3000', 'no-users')).toBe('http://127.0.0.1:3000');
});

it('hands the token off in the fragment, never the query', () => {
const url = embeddedUrl('http://127.0.0.1:3000', PROJECT);
expect(url.startsWith('http://127.0.0.1:3000#wg_token=')).toBe(true);
expect(url).not.toContain('?');
});
});
15 changes: 13 additions & 2 deletions packages/desktop/src/main/laser-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
*/
import { shell, WebContentsView } from 'electron';

import { status } from '@/main/brain';
import { embeddedUrl } from '@/main/operator-session';
import { runtime } from '@/main/runtime';
import type { LaserSyncState } from '@/types/ipc';

Expand All @@ -27,8 +29,15 @@ export function resetLaserView(): void {
* this the embedded UI keeps rendering the previous project's layout.
*/
export function invalidateLaserView(): void {
const previous = loadedUrl;
loadedUrl = null;
if (view && !view.webContents.isDestroyed()) void view.webContents.reload();
if (!view || view.webContents.isDestroyed() || !previous) return;
// Not `reload()`: the UI strips the session token out of the address once it
// has consumed it, so reloading would land on the new project's login screen.
void view.webContents.loadURL(embeddedUrl(previous, status().project)).catch(() => {
// Brain restarting — the next sync loads it.
});
loadedUrl = previous;
}

function ensureView(): WebContentsView | null {
Expand Down Expand Up @@ -65,7 +74,9 @@ export function syncLaser(state: LaserSyncState): void {
if (!v) return;
if (loadedUrl !== url) {
loadedUrl = url;
void v.webContents.loadURL(url).catch(() => {
// Carries a session for the active project, so a project switch doesn't
// strand the operator on a login screen inside their own app.
void v.webContents.loadURL(embeddedUrl(url, status().project)).catch(() => {
// Brain not up yet / refused — the renderer shows its own empty state.
});
}
Expand Down
59 changes: 59 additions & 0 deletions packages/desktop/src/main/operator-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Signing the embedded artist UI in, so switching projects doesn't ask the
* operator for a password every time.
*
* Sessions are per project by design (a token minted for project A is not
* valid once B is active) and that stays true for anything reaching the brain
* over the network. This shortcut applies only to the UI embedded in this
* desktop app: whoever is at the keyboard already owns the store — they can
* read its secrets, add users and mint access keys — so requiring them to
* re-type a password into their own machine protects nothing.
*
* The session is a real store session with a recognisable user agent, so it
* appears in Access → Sessions and can be revoked like any other.
*/
import { signJwt } from '@wavegrid/server';
import { openStore } from '@wavegrid/settings';

const DESKTOP_USER_AGENT = 'wavegrid-desktop (this laptop)';
const TTL_MS = 12 * 60 * 60 * 1000;

/**
* A token for the project's admin, or null when the project has no accounts
* yet — the embedded UI then shows its normal login screen.
*/
export function operatorToken(project: string): string | null {
const store = openStore();
if (!store.hasProject(project)) return null;
const users = store.listUserInfos(project);
const account = users.find((u) => u.role === 'admin') ?? users[0];
if (!account) return null;

// The brain sets this when it starts; set it anyway so a token minted before
// the first start is signed with the same project's secret.
process.env.WG_JWT_SECRET = store.requireSecret(project, 'jwtSecret');

const session = store.createSession(project, {
username: account.username,
role: account.role,
ip: '127.0.0.1',
userAgent: DESKTOP_USER_AGENT,
ttlMs: TTL_MS
});
return signJwt(account.username, {
sid: session.id,
role: account.role,
ttlSec: Math.floor(TTL_MS / 1000)
});
}

/**
* The URL the embedded view should load: the brain's own URL, carrying a
* one-shot token in the fragment. A fragment (never a query) keeps the token
* out of the server's logs, and the UI strips it from the address on arrival.
*/
export function embeddedUrl(url: string, project: string | null): string {
if (!project) return url;
const token = operatorToken(project);
return token ? `${url}#wg_token=${encodeURIComponent(token)}` : url;
}
49 changes: 49 additions & 0 deletions packages/ui/__tests__/hand-off-token.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { takeTokenFromUrl } from '../src/lib/use-auth';

interface FakeWindow {
location: { hash: string; pathname: string; search: string };
history: { replaceState: (a: null, b: string, url: string) => void };
}

function fakeWindow(hash: string): FakeWindow {
const w: FakeWindow = {
location: { hash, pathname: '/', search: '' },
history: {
replaceState: (_a, _b, url) => {
const i = url.indexOf('#');
w.location.hash = i === -1 ? '' : url.slice(i);
}
}
};
(globalThis as unknown as { window: FakeWindow }).window = w;
return w;
}

afterEach(() => {
delete (globalThis as unknown as { window?: FakeWindow }).window;
});

describe('takeTokenFromUrl', () => {
it('reads the handed-off session and erases it from the address', () => {
const w = fakeWindow('#wg_token=abc.def.ghi');
expect(takeTokenFromUrl()).toBe('abc.def.ghi');
expect(w.location.hash).toBe('');
});

it('decodes a percent-encoded token', () => {
fakeWindow('#wg_token=a%2Bb');
expect(takeTokenFromUrl()).toBe('a+b');
});

it('keeps any other fragment the UI owns', () => {
const w = fakeWindow('#tab=patterns&wg_token=abc');
expect(takeTokenFromUrl()).toBe('abc');
expect(w.location.hash).toBe('#tab=patterns');
});

it('is a no-op without a handoff', () => {
const w = fakeWindow('#tab=patterns');
expect(takeTokenFromUrl()).toBeNull();
expect(w.location.hash).toBe('#tab=patterns');
});
});
29 changes: 29 additions & 0 deletions packages/ui/src/lib/use-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,23 @@ function decodePayload(token: string): { sub: string } | null {
}
}

/**
* A session handed to us in the address fragment by a trusted embedder (the
* desktop app, which owns the store this token was minted from). Consumed once
* and erased from the address — the token stays the only credential, so this is
* transport, not a privilege: the server validates it exactly as it would a
* token from the login form.
*/
export function takeTokenFromUrl(): string | null {
if (typeof window === 'undefined') return null;
const hash = window.location.hash.replace(/^#/, '');
const match = /(?:^|&)wg_token=([^&]+)/.exec(hash);
if (!match) return null;
const rest = hash.replace(/(?:^|&)wg_token=[^&]+/, '').replace(/^&/, '');
window.history.replaceState(null, '', window.location.pathname + window.location.search + (rest ? `#${rest}` : ''));
return decodeURIComponent(match[1]);
}

/** Last username signed in on this device — prefilled after a session ends so
* getting back in is one field, not two. */
const LAST_USER_KEY = 'wg_last_user';
Expand All @@ -27,6 +44,18 @@ export function useAuth() {
const [endedSession, setEndedSession] = useState(false);

useEffect(() => {
const handed = takeTokenFromUrl();
if (handed) {
const payload = decodePayload(handed);
if (payload) {
localStorage.setItem('wg_token', handed);
localStorage.setItem(LAST_USER_KEY, payload.sub);
setUser(payload.sub);
setToken(handed);
setChecked(true);
return;
}
}
const stored = localStorage.getItem('wg_token');
if (stored) {
const payload = decodePayload(stored);
Expand Down
Loading