fix(ui): align control-ui device auth token signing
This commit is contained in:
@@ -75,30 +75,6 @@ vi.mock("./device-identity.ts", () => ({
|
|||||||
|
|
||||||
const { GatewayBrowserClient } = await import("./gateway.ts");
|
const { GatewayBrowserClient } = await import("./gateway.ts");
|
||||||
|
|
||||||
function createStorageMock(): Storage {
|
|
||||||
const store = new Map<string, string>();
|
|
||||||
return {
|
|
||||||
get length() {
|
|
||||||
return store.size;
|
|
||||||
},
|
|
||||||
clear() {
|
|
||||||
store.clear();
|
|
||||||
},
|
|
||||||
getItem(key: string) {
|
|
||||||
return store.get(key) ?? null;
|
|
||||||
},
|
|
||||||
key(index: number) {
|
|
||||||
return Array.from(store.keys())[index] ?? null;
|
|
||||||
},
|
|
||||||
removeItem(key: string) {
|
|
||||||
store.delete(key);
|
|
||||||
},
|
|
||||||
setItem(key: string, value: string) {
|
|
||||||
store.set(key, String(value));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLatestWebSocket(): MockWebSocket {
|
function getLatestWebSocket(): MockWebSocket {
|
||||||
const ws = wsInstances.at(-1);
|
const ws = wsInstances.at(-1);
|
||||||
if (!ws) {
|
if (!ws) {
|
||||||
@@ -118,23 +94,8 @@ describe("GatewayBrowserClient", () => {
|
|||||||
publicKey: "public-key", // pragma: allowlist secret
|
publicKey: "public-key", // pragma: allowlist secret
|
||||||
});
|
});
|
||||||
|
|
||||||
const localStorage = createStorageMock();
|
window.localStorage.clear();
|
||||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||||
vi.stubGlobal("localStorage", localStorage);
|
|
||||||
vi.stubGlobal("crypto", {
|
|
||||||
randomUUID: vi.fn(() => "req-1"),
|
|
||||||
subtle: {},
|
|
||||||
});
|
|
||||||
vi.stubGlobal("navigator", {
|
|
||||||
language: "en-GB",
|
|
||||||
platform: "test-platform",
|
|
||||||
userAgent: "test-agent",
|
|
||||||
});
|
|
||||||
vi.stubGlobal("window", {
|
|
||||||
clearTimeout: vi.fn(),
|
|
||||||
localStorage,
|
|
||||||
setTimeout: vi.fn(() => 1),
|
|
||||||
});
|
|
||||||
|
|
||||||
storeDeviceAuthToken({
|
storeDeviceAuthToken({
|
||||||
deviceId: "device-1",
|
deviceId: "device-1",
|
||||||
@@ -148,7 +109,7 @@ describe("GatewayBrowserClient", () => {
|
|||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps shared auth token separate from cached device token", async () => {
|
it("prefers explicit shared auth over cached device tokens", async () => {
|
||||||
const client = new GatewayBrowserClient({
|
const client = new GatewayBrowserClient({
|
||||||
url: "ws://127.0.0.1:18789",
|
url: "ws://127.0.0.1:18789",
|
||||||
token: "shared-auth-token",
|
token: "shared-auth-token",
|
||||||
@@ -162,19 +123,47 @@ describe("GatewayBrowserClient", () => {
|
|||||||
event: "connect.challenge",
|
event: "connect.challenge",
|
||||||
payload: { nonce: "nonce-1" },
|
payload: { nonce: "nonce-1" },
|
||||||
});
|
});
|
||||||
await Promise.resolve();
|
await vi.waitFor(() => expect(ws.sent.length).toBeGreaterThan(0));
|
||||||
|
|
||||||
const connectFrame = JSON.parse(ws.sent.at(-1) ?? "{}") as {
|
const connectFrame = JSON.parse(ws.sent.at(-1) ?? "{}") as {
|
||||||
id?: string;
|
id?: string;
|
||||||
method?: string;
|
method?: string;
|
||||||
params?: { auth?: { token?: string } };
|
params?: { auth?: { token?: string } };
|
||||||
};
|
};
|
||||||
expect(connectFrame.id).toBe("req-1");
|
expect(typeof connectFrame.id).toBe("string");
|
||||||
expect(connectFrame.method).toBe("connect");
|
expect(connectFrame.method).toBe("connect");
|
||||||
expect(connectFrame.params?.auth?.token).toBe("shared-auth-token");
|
expect(connectFrame.params?.auth?.token).toBe("shared-auth-token");
|
||||||
expect(signDevicePayloadMock).toHaveBeenCalledWith("private-key", expect.any(String));
|
expect(signDevicePayloadMock).toHaveBeenCalledWith("private-key", expect.any(String));
|
||||||
const signedPayload = signDevicePayloadMock.mock.calls[0]?.[1];
|
const signedPayload = signDevicePayloadMock.mock.calls[0]?.[1];
|
||||||
|
expect(signedPayload).toContain("|shared-auth-token|nonce-1");
|
||||||
|
expect(signedPayload).not.toContain("stored-device-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses cached device tokens only when no explicit shared auth is provided", async () => {
|
||||||
|
const client = new GatewayBrowserClient({
|
||||||
|
url: "ws://127.0.0.1:18789",
|
||||||
|
});
|
||||||
|
|
||||||
|
client.start();
|
||||||
|
const ws = getLatestWebSocket();
|
||||||
|
ws.emitOpen();
|
||||||
|
ws.emitMessage({
|
||||||
|
type: "event",
|
||||||
|
event: "connect.challenge",
|
||||||
|
payload: { nonce: "nonce-1" },
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(ws.sent.length).toBeGreaterThan(0));
|
||||||
|
|
||||||
|
const connectFrame = JSON.parse(ws.sent.at(-1) ?? "{}") as {
|
||||||
|
id?: string;
|
||||||
|
method?: string;
|
||||||
|
params?: { auth?: { token?: string } };
|
||||||
|
};
|
||||||
|
expect(typeof connectFrame.id).toBe("string");
|
||||||
|
expect(connectFrame.method).toBe("connect");
|
||||||
|
expect(connectFrame.params?.auth?.token).toBe("stored-device-token");
|
||||||
|
expect(signDevicePayloadMock).toHaveBeenCalledWith("private-key", expect.any(String));
|
||||||
|
const signedPayload = signDevicePayloadMock.mock.calls[0]?.[1];
|
||||||
expect(signedPayload).toContain("|stored-device-token|nonce-1");
|
expect(signedPayload).toContain("|stored-device-token|nonce-1");
|
||||||
expect(signedPayload).not.toContain("shared-auth-token");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -205,17 +205,22 @@ export class GatewayBrowserClient {
|
|||||||
const role = "operator";
|
const role = "operator";
|
||||||
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
|
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
|
||||||
let canFallbackToShared = false;
|
let canFallbackToShared = false;
|
||||||
let authToken = this.opts.token;
|
const explicitGatewayToken = this.opts.token?.trim() || undefined;
|
||||||
|
let authToken = explicitGatewayToken;
|
||||||
let deviceToken: string | undefined;
|
let deviceToken: string | undefined;
|
||||||
|
|
||||||
if (isSecureContext) {
|
if (isSecureContext) {
|
||||||
deviceIdentity = await loadOrCreateDeviceIdentity();
|
deviceIdentity = await loadOrCreateDeviceIdentity();
|
||||||
deviceToken = loadDeviceAuthToken({
|
const storedToken = loadDeviceAuthToken({
|
||||||
deviceId: deviceIdentity.deviceId,
|
deviceId: deviceIdentity.deviceId,
|
||||||
role,
|
role,
|
||||||
})?.token;
|
})?.token;
|
||||||
canFallbackToShared = Boolean(deviceToken && this.opts.token);
|
deviceToken = !(explicitGatewayToken || this.opts.password?.trim())
|
||||||
|
? (storedToken ?? undefined)
|
||||||
|
: undefined;
|
||||||
|
canFallbackToShared = Boolean(deviceToken && explicitGatewayToken);
|
||||||
}
|
}
|
||||||
|
authToken = explicitGatewayToken ?? deviceToken;
|
||||||
const auth =
|
const auth =
|
||||||
authToken || this.opts.password
|
authToken || this.opts.password
|
||||||
? {
|
? {
|
||||||
@@ -244,7 +249,7 @@ export class GatewayBrowserClient {
|
|||||||
role,
|
role,
|
||||||
scopes,
|
scopes,
|
||||||
signedAtMs,
|
signedAtMs,
|
||||||
token: deviceToken ?? null,
|
token: authToken ?? null,
|
||||||
nonce,
|
nonce,
|
||||||
});
|
});
|
||||||
const signature = await signDevicePayload(deviceIdentity.privateKey, payload);
|
const signature = await signDevicePayload(deviceIdentity.privateKey, payload);
|
||||||
|
|||||||
Reference in New Issue
Block a user