Skip to content

Commit e0f80cf

Browse files
committed
fix(ui): align control-ui device auth token signing
1 parent 5d22bd0 commit e0f80cf

File tree

2 files changed

+42
-48
lines changed

2 files changed

+42
-48
lines changed

ui/src/ui/gateway.node.test.ts

Lines changed: 33 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -75,30 +75,6 @@ vi.mock("./device-identity.ts", () => ({
7575

7676
const { GatewayBrowserClient } = await import("./gateway.ts");
7777

78-
function createStorageMock(): Storage {
79-
const store = new Map<string, string>();
80-
return {
81-
get length() {
82-
return store.size;
83-
},
84-
clear() {
85-
store.clear();
86-
},
87-
getItem(key: string) {
88-
return store.get(key) ?? null;
89-
},
90-
key(index: number) {
91-
return Array.from(store.keys())[index] ?? null;
92-
},
93-
removeItem(key: string) {
94-
store.delete(key);
95-
},
96-
setItem(key: string, value: string) {
97-
store.set(key, String(value));
98-
},
99-
};
100-
}
101-
10278
function getLatestWebSocket(): MockWebSocket {
10379
const ws = wsInstances.at(-1);
10480
if (!ws) {
@@ -118,23 +94,8 @@ describe("GatewayBrowserClient", () => {
11894
publicKey: "public-key", // pragma: allowlist secret
11995
});
12096

121-
const localStorage = createStorageMock();
97+
window.localStorage.clear();
12298
vi.stubGlobal("WebSocket", MockWebSocket);
123-
vi.stubGlobal("localStorage", localStorage);
124-
vi.stubGlobal("crypto", {
125-
randomUUID: vi.fn(() => "req-1"),
126-
subtle: {},
127-
});
128-
vi.stubGlobal("navigator", {
129-
language: "en-GB",
130-
platform: "test-platform",
131-
userAgent: "test-agent",
132-
});
133-
vi.stubGlobal("window", {
134-
clearTimeout: vi.fn(),
135-
localStorage,
136-
setTimeout: vi.fn(() => 1),
137-
});
13899

139100
storeDeviceAuthToken({
140101
deviceId: "device-1",
@@ -148,7 +109,7 @@ describe("GatewayBrowserClient", () => {
148109
vi.unstubAllGlobals();
149110
});
150111

151-
it("keeps shared auth token separate from cached device token", async () => {
112+
it("prefers explicit shared auth over cached device tokens", async () => {
152113
const client = new GatewayBrowserClient({
153114
url: "ws://127.0.0.1:18789",
154115
token: "shared-auth-token",
@@ -162,19 +123,47 @@ describe("GatewayBrowserClient", () => {
162123
event: "connect.challenge",
163124
payload: { nonce: "nonce-1" },
164125
});
165-
await Promise.resolve();
126+
await vi.waitFor(() => expect(ws.sent.length).toBeGreaterThan(0));
166127

167128
const connectFrame = JSON.parse(ws.sent.at(-1) ?? "{}") as {
168129
id?: string;
169130
method?: string;
170131
params?: { auth?: { token?: string } };
171132
};
172-
expect(connectFrame.id).toBe("req-1");
133+
expect(typeof connectFrame.id).toBe("string");
173134
expect(connectFrame.method).toBe("connect");
174135
expect(connectFrame.params?.auth?.token).toBe("shared-auth-token");
175136
expect(signDevicePayloadMock).toHaveBeenCalledWith("private-key", expect.any(String));
176137
const signedPayload = signDevicePayloadMock.mock.calls[0]?.[1];
138+
expect(signedPayload).toContain("|shared-auth-token|nonce-1");
139+
expect(signedPayload).not.toContain("stored-device-token");
140+
});
141+
142+
it("uses cached device tokens only when no explicit shared auth is provided", async () => {
143+
const client = new GatewayBrowserClient({
144+
url: "ws://127.0.0.1:18789",
145+
});
146+
147+
client.start();
148+
const ws = getLatestWebSocket();
149+
ws.emitOpen();
150+
ws.emitMessage({
151+
type: "event",
152+
event: "connect.challenge",
153+
payload: { nonce: "nonce-1" },
154+
});
155+
await vi.waitFor(() => expect(ws.sent.length).toBeGreaterThan(0));
156+
157+
const connectFrame = JSON.parse(ws.sent.at(-1) ?? "{}") as {
158+
id?: string;
159+
method?: string;
160+
params?: { auth?: { token?: string } };
161+
};
162+
expect(typeof connectFrame.id).toBe("string");
163+
expect(connectFrame.method).toBe("connect");
164+
expect(connectFrame.params?.auth?.token).toBe("stored-device-token");
165+
expect(signDevicePayloadMock).toHaveBeenCalledWith("private-key", expect.any(String));
166+
const signedPayload = signDevicePayloadMock.mock.calls[0]?.[1];
177167
expect(signedPayload).toContain("|stored-device-token|nonce-1");
178-
expect(signedPayload).not.toContain("shared-auth-token");
179168
});
180169
});

ui/src/ui/gateway.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -205,17 +205,22 @@ export class GatewayBrowserClient {
205205
const role = "operator";
206206
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
207207
let canFallbackToShared = false;
208-
let authToken = this.opts.token;
208+
const explicitGatewayToken = this.opts.token?.trim() || undefined;
209+
let authToken = explicitGatewayToken;
209210
let deviceToken: string | undefined;
210211

211212
if (isSecureContext) {
212213
deviceIdentity = await loadOrCreateDeviceIdentity();
213-
deviceToken = loadDeviceAuthToken({
214+
const storedToken = loadDeviceAuthToken({
214215
deviceId: deviceIdentity.deviceId,
215216
role,
216217
})?.token;
217-
canFallbackToShared = Boolean(deviceToken && this.opts.token);
218+
deviceToken = !(explicitGatewayToken || this.opts.password?.trim())
219+
? (storedToken ?? undefined)
220+
: undefined;
221+
canFallbackToShared = Boolean(deviceToken && explicitGatewayToken);
218222
}
223+
authToken = explicitGatewayToken ?? deviceToken;
219224
const auth =
220225
authToken || this.opts.password
221226
? {
@@ -244,7 +249,7 @@ export class GatewayBrowserClient {
244249
role,
245250
scopes,
246251
signedAtMs,
247-
token: deviceToken ?? null,
252+
token: authToken ?? null,
248253
nonce,
249254
});
250255
const signature = await signDevicePayload(deviceIdentity.privateKey, payload);

0 commit comments

Comments
 (0)