Skip to content

Commit 9518d1f

Browse files
committed
fix(auth): coerce persisted device auth tokens
1 parent fbde572 commit 9518d1f

4 files changed

Lines changed: 85 additions & 46 deletions

File tree

src/infra/device-auth-store.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,36 @@ describe("infra/device-auth-store", () => {
7777
});
7878
});
7979

80+
it("normalizes raw persisted token metadata while reading from disk", async () => {
81+
await withTempDir("openclaw-device-auth-", async (stateDir) => {
82+
const env = createEnv(stateDir);
83+
await fs.mkdir(path.dirname(deviceAuthFile(stateDir)), { recursive: true });
84+
await fs.writeFile(
85+
deviceAuthFile(stateDir),
86+
JSON.stringify({
87+
version: 1,
88+
deviceId: "device-1",
89+
tokens: {
90+
" operator ": {
91+
token: "operator-token",
92+
role: { nested: "bad" },
93+
scopes: ["operator.write", "operator.read", 42],
94+
updatedAtMs: "bad-time",
95+
},
96+
},
97+
}) + "\n",
98+
"utf8",
99+
);
100+
101+
expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator", env })).toEqual({
102+
token: "operator-token",
103+
role: "operator",
104+
scopes: ["operator.read", "operator.write"],
105+
updatedAtMs: 0,
106+
});
107+
});
108+
});
109+
80110
it("clears only the requested role and leaves unrelated tokens intact", async () => {
81111
await withTempDir("openclaw-device-auth-", async (stateDir) => {
82112
const env = createEnv(stateDir);

src/infra/device-auth-store.ts

Lines changed: 3 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,62 +3,19 @@ import path from "node:path";
33
import { resolveStateDir } from "../config/paths.js";
44
import {
55
clearDeviceAuthTokenFromStore,
6+
coerceDeviceAuthStore,
67
type DeviceAuthEntry,
8+
type DeviceAuthStore,
79
loadDeviceAuthTokenFromStore,
810
storeDeviceAuthTokenInStore,
911
} from "../shared/device-auth-store.js";
10-
import type { DeviceAuthStore } from "../shared/device-auth.js";
1112
import { privateFileStoreSync } from "./private-file-store.js";
1213

1314
const DEVICE_AUTH_FILE = "device-auth.json";
1415

1516
type StoreCacheEntry = { store: DeviceAuthStore | null; mtimeMs: number; size: number };
1617
const storeReadCache = new Map<string, StoreCacheEntry>();
1718

18-
function isRecord(value: unknown): value is Record<string, unknown> {
19-
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
20-
}
21-
22-
function parseDeviceAuthEntry(role: string, value: unknown): DeviceAuthEntry | null {
23-
if (
24-
!isRecord(value) ||
25-
typeof value.token !== "string" ||
26-
!Array.isArray(value.scopes) ||
27-
!value.scopes.every((scope) => typeof scope === "string") ||
28-
typeof value.updatedAtMs !== "number" ||
29-
!Number.isFinite(value.updatedAtMs)
30-
) {
31-
return null;
32-
}
33-
return {
34-
token: value.token,
35-
role,
36-
scopes: value.scopes,
37-
updatedAtMs: value.updatedAtMs,
38-
};
39-
}
40-
41-
function parseDeviceAuthStore(value: unknown): DeviceAuthStore | null {
42-
if (!isRecord(value) || value.version !== 1 || typeof value.deviceId !== "string") {
43-
return null;
44-
}
45-
if (!isRecord(value.tokens)) {
46-
return null;
47-
}
48-
const tokens: Record<string, DeviceAuthEntry> = {};
49-
for (const [role, rawEntry] of Object.entries(value.tokens)) {
50-
const entry = parseDeviceAuthEntry(role, rawEntry);
51-
if (entry) {
52-
tokens[role] = entry;
53-
}
54-
}
55-
return {
56-
version: 1,
57-
deviceId: value.deviceId,
58-
tokens,
59-
};
60-
}
61-
6219
function storeCacheHit(
6320
cached: StoreCacheEntry | undefined,
6421
stat: { mtimeMs: number; size: number },
@@ -90,7 +47,7 @@ function readStore(filePath: string): DeviceAuthStore | null {
9047
const parsed = privateFileStoreSync(path.dirname(filePath)).readJsonIfExists(
9148
path.basename(filePath),
9249
);
93-
const store = parseDeviceAuthStore(parsed);
50+
const store = coerceDeviceAuthStore(parsed);
9451
storeReadCache.set(filePath, { store, mtimeMs: stat.mtimeMs, size: stat.size });
9552
return store;
9653
} catch {

src/shared/device-auth-store.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it, vi } from "vitest";
22
import {
33
clearDeviceAuthTokenFromStore,
4+
coerceDeviceAuthStore,
45
loadDeviceAuthTokenFromStore,
56
storeDeviceAuthTokenInStore,
67
type DeviceAuthStoreAdapter,
@@ -113,6 +114,43 @@ describe("device-auth-store", () => {
113114
});
114115
});
115116

117+
it("coerces raw persisted stores into canonical token maps", () => {
118+
expect(
119+
coerceDeviceAuthStore({
120+
version: 1,
121+
deviceId: "device-1",
122+
tokens: {
123+
" operator ": {
124+
token: "operator-token",
125+
role: { nested: "bad" },
126+
scopes: ["operator.write", "operator.read", 42],
127+
updatedAtMs: "bad-time",
128+
},
129+
broken: {
130+
token: 123,
131+
role: "broken",
132+
scopes: [],
133+
updatedAtMs: 1,
134+
},
135+
},
136+
}),
137+
).toEqual({
138+
version: 1,
139+
deviceId: "device-1",
140+
tokens: {
141+
operator: {
142+
token: "operator-token",
143+
role: "operator",
144+
scopes: ["operator.read", "operator.write"],
145+
updatedAtMs: 0,
146+
},
147+
},
148+
});
149+
150+
expect(coerceDeviceAuthStore({ version: 2, deviceId: "device-1", tokens: {} })).toBeNull();
151+
expect(coerceDeviceAuthStore({ version: 1, deviceId: "device-1", tokens: [] })).toBeNull();
152+
});
153+
116154
it("stores normalized roles and deduped sorted scopes while preserving same-device tokens", () => {
117155
vi.spyOn(Date, "now").mockReturnValue(1234);
118156
const { adapter, writes, readStore } = createAdapter({

src/shared/device-auth-store.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,20 @@ function copyCanonicalDeviceAuthTokens(
4545
return out;
4646
}
4747

48+
export function coerceDeviceAuthStore(value: unknown): DeviceAuthStore | null {
49+
if (!isRecord(value) || value.version !== 1 || typeof value.deviceId !== "string") {
50+
return null;
51+
}
52+
if (!isRecord(value.tokens)) {
53+
return null;
54+
}
55+
return {
56+
version: 1,
57+
deviceId: value.deviceId,
58+
tokens: copyCanonicalDeviceAuthTokens(value.tokens),
59+
};
60+
}
61+
4862
export function loadDeviceAuthTokenFromStore(params: {
4963
adapter: DeviceAuthStoreAdapter;
5064
deviceId: string;

0 commit comments

Comments
 (0)