






















@@ -38,41 +38,89 @@ afterAll(() => {
3838}
3939});
404041-async function openDeviceTokenWs(): Promise<WebSocket> {
41+async function openDeviceTokenWsWithDetails(
42+params: { issuerGeneration?: string; browserClient?: boolean } = {},
43+): Promise<{
44+ws: WebSocket;
45+deviceId: string;
46+hello: Awaited<ReturnType<typeof connectOk>> & {
47+auth?: { deviceToken?: unknown };
48+};
49+}> {
4250const identityPath = path.join(os.tmpdir(), `openclaw-shared-auth-${process.pid}-${port}.json`);
4351const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } =
4452await import("../infra/device-identity.js");
45-const { approveDevicePairing, requestDevicePairing, rotateDeviceToken } =
53+const { approveDevicePairing, ensureDeviceToken, requestDevicePairing, rotateDeviceToken } =
4654await import("../infra/device-pairing.js");
55+const client = params.browserClient
56+ ? {
57+id: "openclaw-control-ui",
58+version: "1.0.0",
59+platform: "test",
60+mode: "webchat",
61+}
62+ : {
63+id: "test",
64+version: "1.0.0",
65+platform: "test",
66+mode: "test",
67+};
47684869const identity = loadOrCreateDeviceIdentity(identityPath);
4970const pending = await requestDevicePairing({
5071deviceId: identity.deviceId,
5172publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
52-clientId: "test",
53-clientMode: "test",
73+clientId: client.id,
74+clientMode: client.mode,
5475role: "operator",
5576scopes: ["operator.admin"],
5677});
5778await approveDevicePairing(pending.request.requestId, {
5879callerScopes: ["operator.admin"],
5980});
60-const rotated = await rotateDeviceToken({
61-deviceId: identity.deviceId,
62-role: "operator",
63-scopes: ["operator.admin"],
64-});
65-expect(rotated.ok).toBe(true);
81+let issuedDeviceToken = "";
82+if (params.issuerGeneration) {
83+const deviceToken = await ensureDeviceToken({
84+deviceId: identity.deviceId,
85+role: "operator",
86+scopes: ["operator.admin"],
87+issuer: {
88+kind: "shared-gateway-auth",
89+generation: params.issuerGeneration,
90+},
91+});
92+expect(deviceToken?.token).toBeTypeOf("string");
93+issuedDeviceToken = deviceToken?.token ?? "";
94+} else {
95+const rotated = await rotateDeviceToken({
96+deviceId: identity.deviceId,
97+role: "operator",
98+scopes: ["operator.admin"],
99+});
100+expect(rotated.ok).toBe(true);
101+issuedDeviceToken = rotated.ok ? rotated.entry.token : "";
102+}
6610367-const ws = new WebSocket(`ws://127.0.0.1:${port}`);
104+const ws = new WebSocket(
105+`ws://127.0.0.1:${port}`,
106+params.browserClient ? { headers: { origin: `http://127.0.0.1:${port}` } } : undefined,
107+);
68108trackConnectChallengeNonce(ws);
69109await new Promise<void>((resolve) => ws.once("open", resolve));
70-await connectOk(ws, {
110+const hello = (await connectOk(ws, {
71111skipDefaultAuth: true,
112+ client,
72113deviceIdentityPath: identityPath,
73-deviceToken: rotated.ok ? rotated.entry.token : "",
114+deviceToken: issuedDeviceToken,
74115scopes: ["operator.admin"],
75-});
116+})) as Awaited<ReturnType<typeof connectOk>> & {
117+auth?: { deviceToken?: unknown };
118+};
119+return { ws, deviceId: identity.deviceId, hello };
120+}
121+122+async function openDeviceTokenWs(params: { issuerGeneration?: string } = {}): Promise<WebSocket> {
123+const { ws } = await openDeviceTokenWsWithDetails(params);
76124return ws;
77125}
78126@@ -165,6 +213,128 @@ describe("gateway shared auth rotation", () => {
165213await closeWsAndWait(ws);
166214}
167215});
216+217+it("disconnects issuer-tagged device-token websocket sessions after shared token rotation", async () => {
218+const { resolveSharedGatewaySessionGeneration } =
219+await import("./server/ws-shared-generation.js");
220+const issuerGeneration = resolveSharedGatewaySessionGeneration({
221+mode: "token",
222+token: OLD_TOKEN,
223+allowTailscale: false,
224+});
225+expect(issuerGeneration).toBeTypeOf("string");
226+if (!issuerGeneration) {
227+throw new Error("expected shared gateway generation");
228+}
229+const ws = await openDeviceTokenWs({
230+ issuerGeneration,
231+});
232+try {
233+const closed = waitForGatewayWsClose(ws);
234+const res = await sendSharedTokenRotationPatch(ws);
235+236+expect(res.ok).toBe(true);
237+await expect(closed).resolves.toEqual({
238+code: 4001,
239+reason: "gateway auth changed",
240+});
241+} finally {
242+await closeWsAndWait(ws);
243+}
244+});
245+246+it("preserves issuer-tagged browser device tokens on reconnect", async () => {
247+const { getPairedDevice, verifyDeviceToken } = await import("../infra/device-pairing.js");
248+const { resolveSharedGatewaySessionGeneration } =
249+await import("./server/ws-shared-generation.js");
250+const issuerGeneration = resolveSharedGatewaySessionGeneration({
251+mode: "token",
252+token: OLD_TOKEN,
253+allowTailscale: false,
254+});
255+expect(issuerGeneration).toBeTypeOf("string");
256+if (!issuerGeneration) {
257+throw new Error("expected shared gateway generation");
258+}
259+const { ws, deviceId, hello } = await openDeviceTokenWsWithDetails({
260+ issuerGeneration,
261+browserClient: true,
262+});
263+try {
264+const helloDeviceToken = hello.auth?.deviceToken;
265+if (typeof helloDeviceToken !== "string") {
266+throw new Error("expected hello device token");
267+}
268+const paired = await getPairedDevice(deviceId);
269+expect(paired?.tokens?.operator?.issuer).toEqual({
270+kind: "shared-gateway-auth",
271+generation: issuerGeneration,
272+});
273+await expect(
274+verifyDeviceToken({
275+ deviceId,
276+token: helloDeviceToken,
277+role: "operator",
278+scopes: ["operator.admin"],
279+requiredSharedGatewaySessionGeneration: issuerGeneration,
280+}),
281+).resolves.toEqual({
282+ok: true,
283+issuer: {
284+kind: "shared-gateway-auth",
285+generation: issuerGeneration,
286+},
287+});
288+} finally {
289+await closeWsAndWait(ws);
290+}
291+});
292+293+it("keeps issuer metadata when tagged device tokens reconnect through non-browser clients", async () => {
294+const { getPairedDevice, verifyDeviceToken } = await import("../infra/device-pairing.js");
295+const { resolveSharedGatewaySessionGeneration } =
296+await import("./server/ws-shared-generation.js");
297+const issuerGeneration = resolveSharedGatewaySessionGeneration({
298+mode: "token",
299+token: OLD_TOKEN,
300+allowTailscale: false,
301+});
302+expect(issuerGeneration).toBeTypeOf("string");
303+if (!issuerGeneration) {
304+throw new Error("expected shared gateway generation");
305+}
306+const { ws, deviceId, hello } = await openDeviceTokenWsWithDetails({
307+ issuerGeneration,
308+});
309+try {
310+const helloDeviceToken = hello.auth?.deviceToken;
311+if (typeof helloDeviceToken !== "string") {
312+throw new Error("expected hello device token");
313+}
314+const paired = await getPairedDevice(deviceId);
315+expect(paired?.tokens?.operator?.issuer).toEqual({
316+kind: "shared-gateway-auth",
317+generation: issuerGeneration,
318+});
319+await expect(
320+verifyDeviceToken({
321+ deviceId,
322+token: helloDeviceToken,
323+role: "operator",
324+scopes: ["operator.admin"],
325+requiredSharedGatewaySessionGeneration: issuerGeneration,
326+}),
327+).resolves.toEqual({
328+ok: true,
329+issuer: {
330+kind: "shared-gateway-auth",
331+generation: issuerGeneration,
332+},
333+});
334+} finally {
335+await closeWsAndWait(ws);
336+}
337+});
168338});
169339170340describe("gateway shared auth rotation with unchanged SecretRefs", () => {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。