fix(gateway): expire browser tokens after auth rotation

Expire browser-origin Control UI/WebChat device tokens when shared gateway auth rotates by tagging those tokens with the shared-auth generation and enforcing it during verification.

Preserve the issuer tag when a shared-auth-derived device token reconnects through a non-browser client, so reconnect rotation cannot turn it into an untagged long-lived token.

Proof:
- OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs src/gateway/server.shared-auth-rotation.test.ts src/infra/device-pairing.test.ts src/gateway/control-ui.http.test.ts
- GitHub CI run 26535632102: relevant build/runtime/test-type checks green; inherited lint reds match origin/main.
- GitHub CodeQL Critical Quality run 26535631610: network-runtime-boundary green.

Co-authored-by: Pavan Kumar Gondhi <pavangondhi@gmail.com>
This commit is contained in:
Pavan Kumar Gondhi
2026-05-27 21:13:20 +01:00
committed by GitHub
parent d9051151d7
commit c923b07784
7 changed files with 555 additions and 42 deletions
+63 -5
View File
@@ -5,7 +5,11 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { resolveStateDir } from "../config/paths.js";
import { approveDevicePairing, requestDevicePairing } from "../infra/device-pairing.js";
import {
approveDevicePairing,
ensureDeviceToken,
requestDevicePairing,
} from "../infra/device-pairing.js";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import type { ResolvedGatewayAuth } from "./auth.js";
import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH } from "./control-ui-contract.js";
@@ -14,6 +18,7 @@ import {
handleControlUiAvatarRequest,
handleControlUiHttpRequest,
} from "./control-ui.js";
import { resolveSharedGatewaySessionGeneration } from "./server/ws-shared-generation.js";
import { makeMockHttpResponse } from "./test-http-response.js";
describe("handleControlUiHttpRequest", () => {
@@ -279,7 +284,11 @@ describe("handleControlUiHttpRequest", () => {
}
}
async function withPairedOperatorDeviceToken<T>(params: { fn: (token: string) => Promise<T> }) {
async function withPairedOperatorDeviceToken<T>(params: {
issuerGeneration?: string;
browserMetadata?: boolean;
fn: (token: string) => Promise<T>;
}) {
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-device-token-"));
vi.stubEnv("OPENCLAW_HOME", tempHome);
try {
@@ -289,15 +298,31 @@ describe("handleControlUiHttpRequest", () => {
publicKey: "test-public-key",
role: "operator",
scopes: ["operator.read"],
clientId: "openclaw-control-ui",
clientMode: "webchat",
...(params.browserMetadata
? {
clientId: "openclaw-control-ui",
clientMode: "webchat",
}
: {}),
});
const approved = await approveDevicePairing(requested.request.requestId, {
callerScopes: ["operator.read"],
});
expect(approved?.status).toBe("approved");
const operatorToken =
let operatorToken =
approved?.status === "approved" ? approved.device.tokens?.operator?.token : undefined;
if (params.issuerGeneration) {
const issued = await ensureDeviceToken({
deviceId,
role: "operator",
scopes: ["operator.read"],
issuer: {
kind: "shared-gateway-auth",
generation: params.issuerGeneration,
},
});
operatorToken = issued?.token;
}
expect(typeof operatorToken).toBe("string");
return await params.fn(operatorToken ?? "");
} finally {
@@ -575,6 +600,39 @@ describe("handleControlUiHttpRequest", () => {
});
});
it("accepts shared-gateway issuer tagged device tokens on assistant media requests", async () => {
const auth = {
mode: "token",
token: "shared-token",
allowTailscale: false,
} satisfies ResolvedGatewayAuth;
const issuerGeneration = resolveSharedGatewaySessionGeneration(auth);
expect(typeof issuerGeneration).toBe("string");
await withPairedOperatorDeviceToken({
issuerGeneration,
browserMetadata: true,
fn: async (operatorToken) => {
await withAllowedAssistantMediaRoot({
prefix: "ui-media-issued-device-token-",
fn: async (tmpRoot) => {
const filePath = path.join(tmpRoot, "photo.png");
await fs.writeFile(filePath, Buffer.from("not-a-real-png"));
const { res, handled } = await runAssistantMediaRequest({
url: `/__openclaw__/assistant-media?source=${encodeURIComponent(filePath)}`,
method: "GET",
auth,
headers: {
authorization: `Bearer ${operatorToken}`,
},
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
},
});
},
});
});
it("accepts paired operator device tokens in assistant media query auth", async () => {
await withPairedOperatorDeviceToken({
fn: async (operatorToken) => {
+12 -2
View File
@@ -53,6 +53,7 @@ import {
} from "./http-utils.js";
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
import { resolveRequestClientIp } from "./net.js";
import { resolveSharedGatewaySessionGeneration } from "./server/ws-shared-generation.js";
const ROOT_PREFIX = "/";
const CONTROL_UI_ASSISTANT_MEDIA_PREFIX = "/__openclaw__/assistant-media";
@@ -323,7 +324,12 @@ async function authorizeControlUiReadRequest(
retryAfterMs: deviceRateCheck.retryAfterMs,
};
} else {
const deviceTokenOk = await authorizeControlUiDeviceReadToken(token);
const deviceTokenOk = await authorizeControlUiDeviceReadToken(token, {
requiredSharedGatewaySessionGeneration: resolveSharedGatewaySessionGeneration(
opts.auth,
opts.trustedProxies,
),
});
if (deviceTokenOk) {
opts.rateLimiter?.reset(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
opts.rateLimiter?.reset(clientIp, AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET);
@@ -358,7 +364,10 @@ async function authorizeControlUiReadRequest(
return true;
}
async function authorizeControlUiDeviceReadToken(token: string): Promise<boolean> {
async function authorizeControlUiDeviceReadToken(
token: string,
opts: { requiredSharedGatewaySessionGeneration?: string },
): Promise<boolean> {
const pairing = await listDevicePairing();
for (const device of pairing.paired) {
const operatorToken = device.tokens?.[CONTROL_UI_OPERATOR_ROLE];
@@ -373,6 +382,7 @@ async function authorizeControlUiDeviceReadToken(token: string): Promise<boolean
token,
role: CONTROL_UI_OPERATOR_ROLE,
scopes: [CONTROL_UI_OPERATOR_READ_SCOPE],
requiredSharedGatewaySessionGeneration: opts.requiredSharedGatewaySessionGeneration,
});
if (verified.ok) {
return true;
+184 -14
View File
@@ -38,41 +38,89 @@ afterAll(() => {
}
});
async function openDeviceTokenWs(): Promise<WebSocket> {
async function openDeviceTokenWsWithDetails(
params: { issuerGeneration?: string; browserClient?: boolean } = {},
): Promise<{
ws: WebSocket;
deviceId: string;
hello: Awaited<ReturnType<typeof connectOk>> & {
auth?: { deviceToken?: unknown };
};
}> {
const identityPath = path.join(os.tmpdir(), `openclaw-shared-auth-${process.pid}-${port}.json`);
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } =
await import("../infra/device-identity.js");
const { approveDevicePairing, requestDevicePairing, rotateDeviceToken } =
const { approveDevicePairing, ensureDeviceToken, requestDevicePairing, rotateDeviceToken } =
await import("../infra/device-pairing.js");
const client = params.browserClient
? {
id: "openclaw-control-ui",
version: "1.0.0",
platform: "test",
mode: "webchat",
}
: {
id: "test",
version: "1.0.0",
platform: "test",
mode: "test",
};
const identity = loadOrCreateDeviceIdentity(identityPath);
const pending = await requestDevicePairing({
deviceId: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
clientId: "test",
clientMode: "test",
clientId: client.id,
clientMode: client.mode,
role: "operator",
scopes: ["operator.admin"],
});
await approveDevicePairing(pending.request.requestId, {
callerScopes: ["operator.admin"],
});
const rotated = await rotateDeviceToken({
deviceId: identity.deviceId,
role: "operator",
scopes: ["operator.admin"],
});
expect(rotated.ok).toBe(true);
let issuedDeviceToken = "";
if (params.issuerGeneration) {
const deviceToken = await ensureDeviceToken({
deviceId: identity.deviceId,
role: "operator",
scopes: ["operator.admin"],
issuer: {
kind: "shared-gateway-auth",
generation: params.issuerGeneration,
},
});
expect(deviceToken?.token).toBeTypeOf("string");
issuedDeviceToken = deviceToken?.token ?? "";
} else {
const rotated = await rotateDeviceToken({
deviceId: identity.deviceId,
role: "operator",
scopes: ["operator.admin"],
});
expect(rotated.ok).toBe(true);
issuedDeviceToken = rotated.ok ? rotated.entry.token : "";
}
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
const ws = new WebSocket(
`ws://127.0.0.1:${port}`,
params.browserClient ? { headers: { origin: `http://127.0.0.1:${port}` } } : undefined,
);
trackConnectChallengeNonce(ws);
await new Promise<void>((resolve) => ws.once("open", resolve));
await connectOk(ws, {
const hello = (await connectOk(ws, {
skipDefaultAuth: true,
client,
deviceIdentityPath: identityPath,
deviceToken: rotated.ok ? rotated.entry.token : "",
deviceToken: issuedDeviceToken,
scopes: ["operator.admin"],
});
})) as Awaited<ReturnType<typeof connectOk>> & {
auth?: { deviceToken?: unknown };
};
return { ws, deviceId: identity.deviceId, hello };
}
async function openDeviceTokenWs(params: { issuerGeneration?: string } = {}): Promise<WebSocket> {
const { ws } = await openDeviceTokenWsWithDetails(params);
return ws;
}
@@ -165,6 +213,128 @@ describe("gateway shared auth rotation", () => {
await closeWsAndWait(ws);
}
});
it("disconnects issuer-tagged device-token websocket sessions after shared token rotation", async () => {
const { resolveSharedGatewaySessionGeneration } =
await import("./server/ws-shared-generation.js");
const issuerGeneration = resolveSharedGatewaySessionGeneration({
mode: "token",
token: OLD_TOKEN,
allowTailscale: false,
});
expect(issuerGeneration).toBeTypeOf("string");
if (!issuerGeneration) {
throw new Error("expected shared gateway generation");
}
const ws = await openDeviceTokenWs({
issuerGeneration,
});
try {
const closed = waitForGatewayWsClose(ws);
const res = await sendSharedTokenRotationPatch(ws);
expect(res.ok).toBe(true);
await expect(closed).resolves.toEqual({
code: 4001,
reason: "gateway auth changed",
});
} finally {
await closeWsAndWait(ws);
}
});
it("preserves issuer-tagged browser device tokens on reconnect", async () => {
const { getPairedDevice, verifyDeviceToken } = await import("../infra/device-pairing.js");
const { resolveSharedGatewaySessionGeneration } =
await import("./server/ws-shared-generation.js");
const issuerGeneration = resolveSharedGatewaySessionGeneration({
mode: "token",
token: OLD_TOKEN,
allowTailscale: false,
});
expect(issuerGeneration).toBeTypeOf("string");
if (!issuerGeneration) {
throw new Error("expected shared gateway generation");
}
const { ws, deviceId, hello } = await openDeviceTokenWsWithDetails({
issuerGeneration,
browserClient: true,
});
try {
const helloDeviceToken = hello.auth?.deviceToken;
if (typeof helloDeviceToken !== "string") {
throw new Error("expected hello device token");
}
const paired = await getPairedDevice(deviceId);
expect(paired?.tokens?.operator?.issuer).toEqual({
kind: "shared-gateway-auth",
generation: issuerGeneration,
});
await expect(
verifyDeviceToken({
deviceId,
token: helloDeviceToken,
role: "operator",
scopes: ["operator.admin"],
requiredSharedGatewaySessionGeneration: issuerGeneration,
}),
).resolves.toEqual({
ok: true,
issuer: {
kind: "shared-gateway-auth",
generation: issuerGeneration,
},
});
} finally {
await closeWsAndWait(ws);
}
});
it("keeps issuer metadata when tagged device tokens reconnect through non-browser clients", async () => {
const { getPairedDevice, verifyDeviceToken } = await import("../infra/device-pairing.js");
const { resolveSharedGatewaySessionGeneration } =
await import("./server/ws-shared-generation.js");
const issuerGeneration = resolveSharedGatewaySessionGeneration({
mode: "token",
token: OLD_TOKEN,
allowTailscale: false,
});
expect(issuerGeneration).toBeTypeOf("string");
if (!issuerGeneration) {
throw new Error("expected shared gateway generation");
}
const { ws, deviceId, hello } = await openDeviceTokenWsWithDetails({
issuerGeneration,
});
try {
const helloDeviceToken = hello.auth?.deviceToken;
if (typeof helloDeviceToken !== "string") {
throw new Error("expected hello device token");
}
const paired = await getPairedDevice(deviceId);
expect(paired?.tokens?.operator?.issuer).toEqual({
kind: "shared-gateway-auth",
generation: issuerGeneration,
});
await expect(
verifyDeviceToken({
deviceId,
token: helloDeviceToken,
role: "operator",
scopes: ["operator.admin"],
requiredSharedGatewaySessionGeneration: issuerGeneration,
}),
).resolves.toEqual({
ok: true,
issuer: {
kind: "shared-gateway-auth",
generation: issuerGeneration,
},
});
} finally {
await closeWsAndWait(ws);
}
});
});
describe("gateway shared auth rotation with unchanged SecretRefs", () => {
@@ -33,13 +33,23 @@ export type ConnectAuthState = {
deviceTokenCandidateSource?: DeviceTokenCandidateSource;
};
type VerifyDeviceTokenResult = { ok: boolean; reason?: string };
type SharedGatewayAuthDeviceTokenIssuer = {
kind: "shared-gateway-auth";
generation: string;
};
type VerifyDeviceTokenResult = {
ok: boolean;
reason?: string;
issuer?: SharedGatewayAuthDeviceTokenIssuer;
};
type VerifyBootstrapTokenResult = { ok: boolean; reason?: string };
export type ConnectAuthDecision = {
authResult: GatewayAuthResult;
authOk: boolean;
authMethod: GatewayAuthResult["method"];
deviceTokenSharedGatewaySessionGeneration?: string;
};
function mapDeviceTokenAuthFailureReason(params: {
@@ -173,6 +183,7 @@ export async function resolveConnectAuthDecision(params: {
let authResult = params.state.authResult;
let authOk = params.state.authOk;
let authMethod = params.state.authMethod;
let deviceTokenSharedGatewaySessionGeneration: string | undefined;
const bootstrapTokenCandidate = params.state.bootstrapTokenCandidate;
if (params.hasDeviceIdentity && params.deviceId && params.publicKey && bootstrapTokenCandidate) {
@@ -227,6 +238,9 @@ export async function resolveConnectAuthDecision(params: {
if (tokenCheck.ok) {
authOk = true;
authMethod = "device-token";
if (tokenCheck.issuer?.kind === "shared-gateway-auth") {
deviceTokenSharedGatewaySessionGeneration = tokenCheck.issuer.generation;
}
params.rateLimiter?.reset(params.clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
if (params.state.sharedAuthProvided) {
params.rateLimiter?.reset(params.clientIp, AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET);
@@ -244,5 +258,10 @@ export async function resolveConnectAuthDecision(params: {
}
}
return { authResult, authOk, authMethod };
return {
authResult,
authOk,
authMethod,
deviceTokenSharedGatewaySessionGeneration,
};
}
@@ -941,7 +941,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
}
}
({ authResult, authOk, authMethod } = await resolveConnectAuthDecision({
const authDecision = await resolveConnectAuthDecision({
state: {
authResult,
authOk,
@@ -967,8 +967,15 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
role,
scopes,
}),
verifyDeviceToken,
}));
verifyDeviceToken: async (params) =>
await verifyDeviceToken({
...params,
requiredSharedGatewaySessionGeneration: getRequiredSharedGatewaySessionGeneration?.(),
}),
});
({ authResult, authOk, authMethod } = authDecision);
const deviceTokenSharedGatewaySessionGeneration =
authDecision.deviceTokenSharedGatewaySessionGeneration;
pairingLocality = resolvePairingLocality({
connectParams,
isLocalClient,
@@ -991,16 +998,21 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
rejectUnauthorized(authResult);
return;
}
if (authMethod === "token" || authMethod === "password" || authMethod === "trusted-proxy") {
const sharedGatewaySessionGeneration = resolveSharedGatewaySessionGeneration(
resolvedAuth,
trustedProxies,
);
const usesSharedGatewayAuth =
authMethod === "token" || authMethod === "password" || authMethod === "trusted-proxy";
const sharedGatewaySessionGeneration = usesSharedGatewayAuth
? resolveSharedGatewaySessionGeneration(resolvedAuth, trustedProxies)
: undefined;
const sessionUsesSharedGatewayAuth =
usesSharedGatewayAuth || deviceTokenSharedGatewaySessionGeneration !== undefined;
const sessionSharedGatewaySessionGeneration =
sharedGatewaySessionGeneration ?? deviceTokenSharedGatewaySessionGeneration;
if (sessionUsesSharedGatewayAuth) {
const requiredSharedGatewaySessionGeneration =
getRequiredSharedGatewaySessionGeneration?.();
if (
requiredSharedGatewaySessionGeneration !== undefined &&
sharedGatewaySessionGeneration !== requiredSharedGatewaySessionGeneration
sessionSharedGatewaySessionGeneration !== requiredSharedGatewaySessionGeneration
) {
setCloseCause("gateway-auth-rotated", {
authGenerationStale: true,
@@ -1466,9 +1478,23 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
}
const shouldIssueDeviceToken = !trustedProxyAuthOk;
const sharedGatewayAuthIssuer =
sessionSharedGatewaySessionGeneration &&
(deviceTokenSharedGatewaySessionGeneration !== undefined ||
(usesSharedGatewayAuth && (isBrowserOperatorUi || isWebchat)))
? {
kind: "shared-gateway-auth" as const,
generation: sessionSharedGatewaySessionGeneration,
}
: undefined;
const deviceToken =
shouldIssueDeviceToken && device && hasServerApprovedDeviceTokenBaseline
? await ensureDeviceToken({ deviceId: device.id, role, scopes })
? await ensureDeviceToken({
deviceId: device.id,
role,
scopes,
issuer: sharedGatewayAuthIssuer,
})
: null;
const bootstrapDeviceTokens: Array<{
deviceToken: string;
@@ -1593,11 +1619,6 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
});
}
}
const usesSharedGatewayAuth =
authMethod === "token" || authMethod === "password" || authMethod === "trusted-proxy";
const sharedGatewaySessionGeneration = usesSharedGatewayAuth
? resolveSharedGatewaySessionGeneration(resolvedAuth, trustedProxies)
: undefined;
const isTrustedApprovalRuntime =
scopes.includes(APPROVALS_SCOPE) &&
connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT &&
@@ -1609,8 +1630,8 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
connect: connectParams,
connId,
isDeviceTokenAuth: authMethod === "device-token",
usesSharedGatewayAuth,
sharedGatewaySessionGeneration,
usesSharedGatewayAuth: sessionUsesSharedGatewayAuth,
sharedGatewaySessionGeneration: sessionSharedGatewaySessionGeneration,
presenceKey,
clientIp: reportedClientIp,
...(isTrustedApprovalRuntime ? { internal: { approvalRuntime: true } } : {}),
+187
View File
@@ -50,6 +50,25 @@ async function setupPairedNodeDevice(baseDir: string) {
await approveDevicePairing(request.request.requestId, { callerScopes: [] }, baseDir);
}
async function setupPairedBrowserOperatorDevice(baseDir: string) {
const request = await requestDevicePairing(
{
deviceId: "browser-device-1",
publicKey: "public-key-browser-1",
clientId: "openclaw-control-ui",
clientMode: "webchat",
role: "operator",
scopes: ["operator.read"],
},
baseDir,
);
await approveDevicePairing(
request.request.requestId,
{ callerScopes: ["operator.read"] },
baseDir,
);
}
async function setupOperatorToken(scopes: string[]) {
const baseDir = await makeDevicePairingDir();
await setupPairedOperatorDevice(baseDir, scopes);
@@ -1009,6 +1028,174 @@ describe("device pairing tokens", () => {
).resolves.toEqual({ ok: true });
});
test("tags browser tokens minted from shared gateway auth and rejects stale generations", async () => {
const baseDir = await makeDevicePairingDir();
await setupPairedBrowserOperatorDevice(baseDir);
const legacy = await getPairedDevice("browser-device-1", baseDir);
const legacyToken = requireToken(legacy?.tokens?.operator?.token);
await expect(
verifyDeviceToken({
deviceId: "browser-device-1",
token: legacyToken,
role: "operator",
scopes: ["operator.read"],
requiredSharedGatewaySessionGeneration: "old-generation",
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "legacy-browser-token" });
const oldIssued = await ensureDeviceToken({
deviceId: "browser-device-1",
role: "operator",
scopes: ["operator.read"],
issuer: { kind: "shared-gateway-auth", generation: "old-generation" },
baseDir,
});
expect(oldIssued?.token).not.toBe(legacyToken);
expect(oldIssued?.issuer).toEqual({
kind: "shared-gateway-auth",
generation: "old-generation",
});
await expect(
verifyDeviceToken({
deviceId: "browser-device-1",
token: requireToken(oldIssued?.token),
role: "operator",
scopes: ["operator.read"],
requiredSharedGatewaySessionGeneration: "old-generation",
baseDir,
}),
).resolves.toEqual({
ok: true,
issuer: { kind: "shared-gateway-auth", generation: "old-generation" },
});
await expect(
verifyDeviceToken({
deviceId: "browser-device-1",
token: requireToken(oldIssued?.token),
role: "operator",
scopes: ["operator.read"],
requiredSharedGatewaySessionGeneration: "new-generation",
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "issuer-generation-stale" });
const newIssued = await ensureDeviceToken({
deviceId: "browser-device-1",
role: "operator",
scopes: ["operator.read"],
issuer: { kind: "shared-gateway-auth", generation: "new-generation" },
baseDir,
});
expect(newIssued?.token).not.toBe(oldIssued?.token);
expect(newIssued?.issuer).toEqual({
kind: "shared-gateway-auth",
generation: "new-generation",
});
await expect(
verifyDeviceToken({
deviceId: "browser-device-1",
token: requireToken(newIssued?.token),
role: "operator",
scopes: ["operator.read"],
requiredSharedGatewaySessionGeneration: "new-generation",
baseDir,
}),
).resolves.toEqual({
ok: true,
issuer: { kind: "shared-gateway-auth", generation: "new-generation" },
});
const rotated = await rotateDeviceToken({
deviceId: "browser-device-1",
role: "operator",
scopes: ["operator.read"],
baseDir,
});
const rotatedEntry = requireRotatedEntry(rotated);
expect(rotatedEntry.issuer).toEqual({
kind: "shared-gateway-auth",
generation: "new-generation",
});
await expect(
verifyDeviceToken({
deviceId: "browser-device-1",
token: rotatedEntry.token,
role: "operator",
scopes: ["operator.read"],
requiredSharedGatewaySessionGeneration: "new-generation",
baseDir,
}),
).resolves.toEqual({
ok: true,
issuer: { kind: "shared-gateway-auth", generation: "new-generation" },
});
});
test("keeps ambiguous legacy device tokens valid across shared gateway auth rotation", async () => {
const baseDir = await makeDevicePairingDir();
await setupPairedOperatorDevice(baseDir, ["operator.read"]);
const paired = await getPairedDevice("device-1", baseDir);
const token = requireToken(paired?.tokens?.operator?.token);
await expect(
verifyDeviceToken({
deviceId: "device-1",
token,
role: "operator",
scopes: ["operator.read"],
requiredSharedGatewaySessionGeneration: "new-generation",
baseDir,
}),
).resolves.toEqual({ ok: true });
const issuedFromBrowserSharedAuth = await ensureDeviceToken({
deviceId: "device-1",
role: "operator",
scopes: ["operator.read"],
issuer: { kind: "shared-gateway-auth", generation: "new-generation" },
baseDir,
});
expect(issuedFromBrowserSharedAuth?.token).not.toBe(token);
expect(issuedFromBrowserSharedAuth?.issuer).toEqual({
kind: "shared-gateway-auth",
generation: "new-generation",
});
await expect(
verifyDeviceToken({
deviceId: "device-1",
token: requireToken(issuedFromBrowserSharedAuth?.token),
role: "operator",
scopes: ["operator.read"],
requiredSharedGatewaySessionGeneration: "new-generation",
baseDir,
}),
).resolves.toEqual({
ok: true,
issuer: { kind: "shared-gateway-auth", generation: "new-generation" },
});
const issuedWithoutSharedAuth = await ensureDeviceToken({
deviceId: "device-1",
role: "operator",
scopes: ["operator.read"],
baseDir,
});
expect(issuedWithoutSharedAuth?.token).not.toBe(issuedFromBrowserSharedAuth?.token);
expect(issuedWithoutSharedAuth?.issuer).toBeUndefined();
await expect(
verifyDeviceToken({
deviceId: "device-1",
token: requireToken(issuedWithoutSharedAuth?.token),
role: "operator",
scopes: ["operator.read"],
requiredSharedGatewaySessionGeneration: "new-generation",
baseDir,
}),
).resolves.toEqual({ ok: true });
});
test("normalizes legacy node token scopes back to [] on re-approval", async () => {
const baseDir = await makeDevicePairingDir();
await setupPairedNodeDevice(baseDir);
+50 -2
View File
@@ -45,6 +45,10 @@ export type DeviceAuthToken = {
token: string;
role: string;
scopes: string[];
issuer?: {
kind: "shared-gateway-auth";
generation: string;
};
createdAtMs: number;
rotatedAtMs?: number;
revokedAtMs?: number;
@@ -139,6 +143,9 @@ type DevicePairingStateFile = {
const PENDING_TTL_MS = 5 * 60 * 1000;
const OPERATOR_ROLE = "operator";
const OPERATOR_SCOPE_PREFIX = "operator.";
const SHARED_GATEWAY_AUTH_ISSUER_KIND = "shared-gateway-auth";
const BROWSER_DEVICE_CLIENT_IDS = new Set(["openclaw-control-ui", "webchat-ui"]);
const BROWSER_DEVICE_CLIENT_MODE = "webchat";
const withLock = createAsyncLock();
@@ -411,10 +418,31 @@ function cloneDeviceTokens(device: PairedDevice): Record<string, DeviceAuthToken
return device.tokens ? { ...device.tokens } : {};
}
function isBrowserRelatedPairedDevice(device: Pick<PairedDevice, "clientId" | "clientMode">) {
const clientMode = device.clientMode?.trim().toLowerCase();
if (clientMode === BROWSER_DEVICE_CLIENT_MODE) {
return true;
}
const clientId = device.clientId?.trim().toLowerCase();
return clientId ? BROWSER_DEVICE_CLIENT_IDS.has(clientId) : false;
}
function deviceTokenIssuerMatches(
entry: DeviceAuthToken,
issuer: DeviceAuthToken["issuer"] | undefined,
): boolean {
if (!issuer) {
return !entry.issuer;
}
return entry.issuer?.kind === issuer.kind && entry.issuer.generation === issuer.generation;
}
function buildDeviceAuthToken(params: {
role: string;
scopes: string[];
issuer?: DeviceAuthToken["issuer"];
existing?: DeviceAuthToken;
preserveExistingIssuer?: boolean;
now: number;
rotatedAtMs?: number;
}): DeviceAuthToken {
@@ -422,6 +450,7 @@ function buildDeviceAuthToken(params: {
token: newToken(),
role: params.role,
scopes: params.scopes,
issuer: params.issuer ?? (params.preserveExistingIssuer ? params.existing?.issuer : undefined),
createdAtMs: params.existing?.createdAtMs ?? params.now,
rotatedAtMs: params.rotatedAtMs,
revokedAtMs: undefined,
@@ -897,8 +926,9 @@ export async function verifyDeviceToken(params: {
token: string;
role: string;
scopes: string[];
requiredSharedGatewaySessionGeneration?: string;
baseDir?: string;
}): Promise<{ ok: boolean; reason?: string }> {
}): Promise<{ ok: boolean; reason?: string; issuer?: DeviceAuthToken["issuer"] }> {
return await withLock(async () => {
const state = await loadState(params.baseDir);
const device = getPairedDeviceFromState(state, params.deviceId);
@@ -919,6 +949,19 @@ export async function verifyDeviceToken(params: {
if (!verifyPairingToken(params.token, entry.token)) {
return { ok: false, reason: "token-mismatch" };
}
if (
entry.issuer?.kind === SHARED_GATEWAY_AUTH_ISSUER_KIND &&
entry.issuer.generation !== params.requiredSharedGatewaySessionGeneration
) {
return { ok: false, reason: "issuer-generation-stale" };
}
if (
!entry.issuer &&
params.requiredSharedGatewaySessionGeneration !== undefined &&
isBrowserRelatedPairedDevice(device)
) {
return { ok: false, reason: "legacy-browser-token" };
}
const approvedScopes = resolveApprovedDeviceScopeBaseline(device);
if (
!scopesWithinApprovedDeviceBaseline({
@@ -938,7 +981,7 @@ export async function verifyDeviceToken(params: {
device.tokens[role] = entry;
state.pairedByDeviceId[device.deviceId] = device;
await persistState(state, params.baseDir, "paired");
return { ok: true };
return entry.issuer ? { ok: true, issuer: entry.issuer } : { ok: true };
});
}
@@ -946,6 +989,7 @@ export async function ensureDeviceToken(params: {
deviceId: string;
role: string;
scopes: string[];
issuer?: DeviceAuthToken["issuer"];
baseDir?: string;
}): Promise<DeviceAuthToken | null> {
return await withLock(async () => {
@@ -976,8 +1020,10 @@ export async function ensureDeviceToken(params: {
scopes: existing.scopes,
approvedScopes,
});
const issuerAllowsReuse = deviceTokenIssuerMatches(existing, params.issuer);
if (
existingWithinApproved &&
issuerAllowsReuse &&
roleScopesAllow({ role, requestedScopes, allowedScopes: existing.scopes })
) {
return existing;
@@ -987,6 +1033,7 @@ export async function ensureDeviceToken(params: {
const next = buildDeviceAuthToken({
role,
scopes: requestedScopes,
issuer: params.issuer,
existing,
now,
rotatedAtMs: existing ? now : undefined,
@@ -1076,6 +1123,7 @@ export async function rotateDeviceToken(params: {
role,
scopes: requestedScopes,
existing,
preserveExistingIssuer: true,
now,
rotatedAtMs: now,
});