diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts index 7ee747abf5..c38ca3fad3 100644 --- a/src/gateway/server.auth.e2e.test.ts +++ b/src/gateway/server.auth.e2e.test.ts @@ -948,6 +948,66 @@ describe("gateway server auth/connect", () => { restoreGatewayToken(prevToken); }); + test("allows legacy paired devices without role/scope metadata", async () => { + const { resolvePairingPaths, readJsonFile } = await import("../infra/pairing-files.js"); + const { writeJsonAtomic } = await import("../infra/json-files.js"); + const { getPairedDevice } = await import("../infra/device-pairing.js"); + const { + device, + identity: { deviceId }, + } = await createSignedDevice({ + token: "secret", + scopes: ["operator.read"], + clientId: TEST_OPERATOR_CLIENT.id, + clientMode: TEST_OPERATOR_CLIENT.mode, + }); + const { server, ws, port, prevToken } = await startServerWithClient("secret"); + let ws2: WebSocket | undefined; + try { + const initial = await connectReq(ws, { + token: "secret", + scopes: ["operator.read"], + client: TEST_OPERATOR_CLIENT, + device, + }); + if (!initial.ok) { + await approvePendingPairingIfNeeded(); + } + + const { pairedPath } = resolvePairingPaths(undefined, "devices"); + const paired = + (await readJsonFile>(pairedPath)) ?? + {}; + const legacy = paired[deviceId]; + expect(legacy).toBeDefined(); + expect(legacy?.roles).toBeUndefined(); + expect(legacy?.scopes).toBeUndefined(); + delete legacy.roles; + delete legacy.scopes; + await writeJsonAtomic(pairedPath, paired); + ws.close(); + + ws2 = new WebSocket(`ws://127.0.0.1:${port}`); + await new Promise((resolve) => ws2.once("open", resolve)); + const upgraded = await connectReq(ws2, { + token: "secret", + scopes: ["operator.admin"], + client: TEST_OPERATOR_CLIENT, + device, + }); + expect(upgraded.ok).toBe(true); + + const refreshed = await getPairedDevice(deviceId); + expect(refreshed?.roles).toContain("operator"); + expect(refreshed?.scopes).toContain("operator.admin"); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + ws.close(); + ws2?.close(); + } + }); + test("rejects revoked device token", async () => { const { revokeDeviceToken } = await import("../infra/device-pairing.js"); const { server, ws, port, prevToken } = await startServerWithClient("secret"); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index a3d5f9c29c..43cdd94e16 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -711,43 +711,47 @@ export function attachGatewayWsMessageHandler(params: { return; } } else { + const hasLegacyPairedMetadata = + paired.roles === undefined && paired.scopes === undefined; const pairedRoles = Array.isArray(paired.roles) ? paired.roles : paired.role ? [paired.role] : []; - const allowedRoles = new Set(pairedRoles); - if (allowedRoles.size === 0) { - logUpgradeAudit("role-upgrade", pairedRoles, paired.scopes); - const ok = await requirePairing("role-upgrade"); - if (!ok) { - return; - } - } else if (!allowedRoles.has(role)) { - logUpgradeAudit("role-upgrade", pairedRoles, paired.scopes); - const ok = await requirePairing("role-upgrade"); - if (!ok) { - return; - } - } - - const pairedScopes = Array.isArray(paired.scopes) ? paired.scopes : []; - if (scopes.length > 0) { - if (pairedScopes.length === 0) { - logUpgradeAudit("scope-upgrade", pairedRoles, pairedScopes); - const ok = await requirePairing("scope-upgrade"); + if (!hasLegacyPairedMetadata) { + const allowedRoles = new Set(pairedRoles); + if (allowedRoles.size === 0) { + logUpgradeAudit("role-upgrade", pairedRoles, paired.scopes); + const ok = await requirePairing("role-upgrade"); if (!ok) { return; } - } else { - const allowedScopes = new Set(pairedScopes); - const missingScope = scopes.find((scope) => !allowedScopes.has(scope)); - if (missingScope) { + } else if (!allowedRoles.has(role)) { + logUpgradeAudit("role-upgrade", pairedRoles, paired.scopes); + const ok = await requirePairing("role-upgrade"); + if (!ok) { + return; + } + } + + const pairedScopes = Array.isArray(paired.scopes) ? paired.scopes : []; + if (scopes.length > 0) { + if (pairedScopes.length === 0) { logUpgradeAudit("scope-upgrade", pairedRoles, pairedScopes); const ok = await requirePairing("scope-upgrade"); if (!ok) { return; } + } else { + const allowedScopes = new Set(pairedScopes); + const missingScope = scopes.find((scope) => !allowedScopes.has(scope)); + if (missingScope) { + logUpgradeAudit("scope-upgrade", pairedRoles, pairedScopes); + const ok = await requirePairing("scope-upgrade"); + if (!ok) { + return; + } + } } } }