From 9518d1f27ccd8564e6f2ee108ee0ab57624e906e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 31 May 2026 15:08:25 +0200 Subject: [PATCH] fix(auth): coerce persisted device auth tokens --- src/infra/device-auth-store.test.ts | 30 +++++++++++++++++ src/infra/device-auth-store.ts | 49 ++-------------------------- src/shared/device-auth-store.test.ts | 38 +++++++++++++++++++++ src/shared/device-auth-store.ts | 14 ++++++++ 4 files changed, 85 insertions(+), 46 deletions(-) diff --git a/src/infra/device-auth-store.test.ts b/src/infra/device-auth-store.test.ts index 185a79bb9d7..4212b5c8e0a 100644 --- a/src/infra/device-auth-store.test.ts +++ b/src/infra/device-auth-store.test.ts @@ -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); diff --git a/src/infra/device-auth-store.ts b/src/infra/device-auth-store.ts index 8bc997a9a2c..2d01be2183d 100644 --- a/src/infra/device-auth-store.ts +++ b/src/infra/device-auth-store.ts @@ -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(); -function isRecord(value: unknown): value is Record { - 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 = {}; - 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 { diff --git a/src/shared/device-auth-store.test.ts b/src/shared/device-auth-store.test.ts index 3d90287b3e1..6213ed01362 100644 --- a/src/shared/device-auth-store.test.ts +++ b/src/shared/device-auth-store.test.ts @@ -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({ diff --git a/src/shared/device-auth-store.ts b/src/shared/device-auth-store.ts index 29bd4c7e686..948bb565231 100644 --- a/src/shared/device-auth-store.ts +++ b/src/shared/device-auth-store.ts @@ -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;