mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 10:44:08 +00:00
fix(auth): coerce persisted device auth tokens
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user