fix(auth): coerce persisted device auth tokens

This commit is contained in:
Vincent Koc
2026-05-31 15:08:25 +02:00
parent fbde572491
commit 9518d1f27c
4 changed files with 85 additions and 46 deletions

View File

@@ -77,6 +77,36 @@ describe("infra/device-auth-store", () => {
});
});
it("normalizes raw persisted token metadata while reading from disk", async () => {
await withTempDir("openclaw-device-auth-", async (stateDir) => {
const env = createEnv(stateDir);
await fs.mkdir(path.dirname(deviceAuthFile(stateDir)), { recursive: true });
await fs.writeFile(
deviceAuthFile(stateDir),
JSON.stringify({
version: 1,
deviceId: "device-1",
tokens: {
" operator ": {
token: "operator-token",
role: { nested: "bad" },
scopes: ["operator.write", "operator.read", 42],
updatedAtMs: "bad-time",
},
},
}) + "\n",
"utf8",
);
expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator", env })).toEqual({
token: "operator-token",
role: "operator",
scopes: ["operator.read", "operator.write"],
updatedAtMs: 0,
});
});
});
it("clears only the requested role and leaves unrelated tokens intact", async () => {
await withTempDir("openclaw-device-auth-", async (stateDir) => {
const env = createEnv(stateDir);

View File

@@ -3,11 +3,12 @@ import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import {
clearDeviceAuthTokenFromStore,
coerceDeviceAuthStore,
type DeviceAuthEntry,
type DeviceAuthStore,
loadDeviceAuthTokenFromStore,
storeDeviceAuthTokenInStore,
} from "../shared/device-auth-store.js";
import type { DeviceAuthStore } from "../shared/device-auth.js";
import { privateFileStoreSync } from "./private-file-store.js";
const DEVICE_AUTH_FILE = "device-auth.json";
@@ -15,50 +16,6 @@ const DEVICE_AUTH_FILE = "device-auth.json";
type StoreCacheEntry = { store: DeviceAuthStore | null; mtimeMs: number; size: number };
const storeReadCache = new Map<string, StoreCacheEntry>();
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function parseDeviceAuthEntry(role: string, value: unknown): DeviceAuthEntry | null {
if (
!isRecord(value) ||
typeof value.token !== "string" ||
!Array.isArray(value.scopes) ||
!value.scopes.every((scope) => typeof scope === "string") ||
typeof value.updatedAtMs !== "number" ||
!Number.isFinite(value.updatedAtMs)
) {
return null;
}
return {
token: value.token,
role,
scopes: value.scopes,
updatedAtMs: value.updatedAtMs,
};
}
function parseDeviceAuthStore(value: unknown): DeviceAuthStore | null {
if (!isRecord(value) || value.version !== 1 || typeof value.deviceId !== "string") {
return null;
}
if (!isRecord(value.tokens)) {
return null;
}
const tokens: Record<string, DeviceAuthEntry> = {};
for (const [role, rawEntry] of Object.entries(value.tokens)) {
const entry = parseDeviceAuthEntry(role, rawEntry);
if (entry) {
tokens[role] = entry;
}
}
return {
version: 1,
deviceId: value.deviceId,
tokens,
};
}
function storeCacheHit(
cached: StoreCacheEntry | undefined,
stat: { mtimeMs: number; size: number },
@@ -90,7 +47,7 @@ function readStore(filePath: string): DeviceAuthStore | null {
const parsed = privateFileStoreSync(path.dirname(filePath)).readJsonIfExists(
path.basename(filePath),
);
const store = parseDeviceAuthStore(parsed);
const store = coerceDeviceAuthStore(parsed);
storeReadCache.set(filePath, { store, mtimeMs: stat.mtimeMs, size: stat.size });
return store;
} catch {

View File

@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import {
clearDeviceAuthTokenFromStore,
coerceDeviceAuthStore,
loadDeviceAuthTokenFromStore,
storeDeviceAuthTokenInStore,
type DeviceAuthStoreAdapter,
@@ -113,6 +114,43 @@ describe("device-auth-store", () => {
});
});
it("coerces raw persisted stores into canonical token maps", () => {
expect(
coerceDeviceAuthStore({
version: 1,
deviceId: "device-1",
tokens: {
" operator ": {
token: "operator-token",
role: { nested: "bad" },
scopes: ["operator.write", "operator.read", 42],
updatedAtMs: "bad-time",
},
broken: {
token: 123,
role: "broken",
scopes: [],
updatedAtMs: 1,
},
},
}),
).toEqual({
version: 1,
deviceId: "device-1",
tokens: {
operator: {
token: "operator-token",
role: "operator",
scopes: ["operator.read", "operator.write"],
updatedAtMs: 0,
},
},
});
expect(coerceDeviceAuthStore({ version: 2, deviceId: "device-1", tokens: {} })).toBeNull();
expect(coerceDeviceAuthStore({ version: 1, deviceId: "device-1", tokens: [] })).toBeNull();
});
it("stores normalized roles and deduped sorted scopes while preserving same-device tokens", () => {
vi.spyOn(Date, "now").mockReturnValue(1234);
const { adapter, writes, readStore } = createAdapter({

View File

@@ -45,6 +45,20 @@ function copyCanonicalDeviceAuthTokens(
return out;
}
export function coerceDeviceAuthStore(value: unknown): DeviceAuthStore | null {
if (!isRecord(value) || value.version !== 1 || typeof value.deviceId !== "string") {
return null;
}
if (!isRecord(value.tokens)) {
return null;
}
return {
version: 1,
deviceId: value.deviceId,
tokens: copyCanonicalDeviceAuthTokens(value.tokens),
};
}
export function loadDeviceAuthTokenFromStore(params: {
adapter: DeviceAuthStoreAdapter;
deviceId: string;