mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } } : {}),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user