refactor: store msteams delegated tokens in sqlite

This commit is contained in:
Peter Steinberger
2026-05-08 16:46:50 +01:00
parent 6787f90342
commit 5fc91c2cb3
6 changed files with 185 additions and 23 deletions

View File

@@ -274,9 +274,10 @@ The remaining cleanup is mostly consolidation and deletion:
- The generic plugin SDK persistent-dedupe helper no longer exposes file-shaped
options. Callers provide SQLite scope keys and durable dedupe rows live in
shared plugin state.
- Microsoft Teams SSO tokens moved from a locked JSON file to SQLite plugin
state. Doctor imports `msteams-sso-tokens.json`, rebuilds canonical token
keys from payloads, and removes the source file.
- Microsoft Teams SSO and delegated OAuth tokens moved from locked JSON files
to SQLite plugin state. Doctor imports `msteams-sso-tokens.json` and
`msteams-delegated.json`, rebuilds canonical SSO token keys from payloads,
and removes the source files.
- Matrix sync cache state moved from `bot-storage.json` to SQLite plugin
state. Doctor imports legacy raw or wrapped sync payloads and removes the
source file.
@@ -603,10 +604,11 @@ Move these into the global database:
`*.telegram-sent-messages.json`, `*.telegram-topic-names.json`, and
`thread-bindings-*.json`; the Telegram doctor/setup migration imports and
removes the legacy files.
- Microsoft Teams conversations, polls, pending uploads, and feedback
learnings now use SQLite plugin state/blob namespaces
(`conversations`, `polls`, `pending-uploads`, `feedback-learnings`) instead
of `msteams-conversations.json`, `msteams-polls.json`,
- Microsoft Teams conversations, polls, delegated tokens, pending uploads, and
feedback learnings now use SQLite plugin state/blob namespaces
(`conversations`, `polls`, `delegated-tokens`, `pending-uploads`,
`feedback-learnings`) instead of `msteams-conversations.json`,
`msteams-polls.json`, `msteams-delegated.json`,
`msteams-pending-uploads.json`, and `*.learnings.json`; the Microsoft Teams
doctor/setup migration imports and removes the legacy files.
- Matrix sync cache, storage metadata, thread bindings, inbound dedupe markers,
@@ -1012,6 +1014,7 @@ Add a repo check that fails new runtime writes to legacy state paths:
- Telegram `thread-bindings-*.json`
- Microsoft Teams `msteams-conversations.json`
- Microsoft Teams `msteams-polls.json`
- Microsoft Teams `msteams-delegated.json`
- Microsoft Teams `msteams-pending-uploads.json`
- Microsoft Teams `*.learnings.json`
- Matrix `thread-bindings.json`

View File

@@ -14,6 +14,7 @@ import { setMSTeamsRuntime } from "./runtime.js";
import { createMSTeamsSsoTokenStore } from "./sso-token-store.js";
import { detectMSTeamsLegacyStateMigrations } from "./state-migrations.js";
import { msteamsRuntimeStub } from "./test-runtime.js";
import { loadDelegatedTokens } from "./token.js";
const tempDirs: string[] = [];
@@ -160,6 +161,30 @@ describe("Microsoft Teams legacy state migrations", () => {
expect(fs.existsSync(tokenFile)).toBe(false);
});
it("imports delegated token files into SQLite plugin state", async () => {
const stateDir = makeStateDir();
const tokenFile = path.join(stateDir, "msteams-delegated.json");
fs.writeFileSync(
tokenFile,
`${JSON.stringify({
accessToken: "access-token",
refreshToken: "refresh-token",
expiresAt: 1_900_000_000_000,
scopes: ["ChatMessage.Send", "offline_access"],
userPrincipalName: "user@example.com",
})}\n`,
);
await applyPlan(stateDir, "Microsoft Teams delegated token");
expect(loadDelegatedTokens()).toMatchObject({
accessToken: "access-token",
refreshToken: "refresh-token",
userPrincipalName: "user@example.com",
});
expect(fs.existsSync(tokenFile)).toBe(false);
});
it("imports feedback learning files into SQLite plugin state", async () => {
const stateDir = makeStateDir();
const learningFile = path.join(stateDir, "bXN0ZWFtczp1c2VyMQ.learnings.json");

View File

@@ -12,6 +12,11 @@ import {
MSTEAMS_SSO_TOKEN_STORE_FILENAME,
parseMSTeamsSsoTokenStoreData,
} from "./sso-token-store.js";
import {
MSTEAMS_DELEGATED_TOKEN_FILENAME,
MSTEAMS_DELEGATED_TOKEN_NAMESPACE,
parseMSTeamsDelegatedTokens,
} from "./token.js";
const MSTEAMS_PLUGIN_ID = "msteams";
const PENDING_UPLOAD_TTL_MS = 5 * 60 * 1000;
@@ -152,6 +157,26 @@ function importSsoTokens(filePath: string, env: NodeJS.ProcessEnv): ImportResult
return { imported, warnings: [] };
}
function importDelegatedTokens(filePath: string, env: NodeJS.ProcessEnv): ImportResult {
const tokens = parseMSTeamsDelegatedTokens(readJsonFile(filePath));
if (!tokens) {
return {
imported: 0,
warnings: [`Skipped invalid Microsoft Teams delegated token file: ${filePath}`],
};
}
upsertPluginStateMigrationEntry({
pluginId: MSTEAMS_PLUGIN_ID,
namespace: MSTEAMS_DELEGATED_TOKEN_NAMESPACE,
key: "current",
value: tokens,
createdAt: Date.now(),
env,
});
fs.rmSync(filePath, { force: true });
return { imported: 1, warnings: [] };
}
function importPendingUploads(filePath: string, env: NodeJS.ProcessEnv): ImportResult {
const raw = readJsonFile(filePath);
if (!isRecord(raw) || raw.version !== 1 || !isRecord(raw.uploads)) {
@@ -345,6 +370,17 @@ export function detectMSTeamsLegacyStateMigrations(params: {
}),
);
}
const delegatedTokens = path.join(params.stateDir, MSTEAMS_DELEGATED_TOKEN_FILENAME);
if (fs.existsSync(delegatedTokens)) {
plans.push(
pluginStatePlan({
label: "Microsoft Teams delegated token",
sourcePath: delegatedTokens,
namespace: MSTEAMS_DELEGATED_TOKEN_NAMESPACE,
importSource: importDelegatedTokens,
}),
);
}
if (collectLearningFiles(params.stateDir).length > 0) {
plans.push(
pluginStatePlan({

View File

@@ -1,6 +1,16 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resetPluginStateStoreForTests } from "openclaw/plugin-sdk/plugin-state-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readAccessToken } from "./token-response.js";
import { hasConfiguredMSTeamsCredentials, resolveMSTeamsCredentials } from "./token.js";
import {
loadDelegatedTokens,
parseMSTeamsDelegatedTokens,
saveDelegatedTokens,
hasConfiguredMSTeamsCredentials,
resolveMSTeamsCredentials,
} from "./token.js";
vi.mock("./secret-input.js", () => ({
normalizeSecretInputString: (v: unknown) =>
@@ -19,6 +29,7 @@ const ENV_KEYS = [
"MSTEAMS_CERTIFICATE_THUMBPRINT",
"MSTEAMS_USE_MANAGED_IDENTITY",
"MSTEAMS_MANAGED_IDENTITY_CLIENT_ID",
"OPENCLAW_STATE_DIR",
] as const;
let savedEnv: Record<string, string | undefined> = {};
@@ -243,6 +254,63 @@ describe("token backward compatibility", () => {
});
});
describe("delegated token storage", () => {
const tempDirs: string[] = [];
beforeEach(() => {
saveAndClearEnv();
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-msteams-token-"));
tempDirs.push(stateDir);
process.env.OPENCLAW_STATE_DIR = stateDir;
});
afterEach(() => {
resetPluginStateStoreForTests();
restoreEnv();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("stores delegated tokens in SQLite plugin state", () => {
saveDelegatedTokens({
accessToken: "access-token",
refreshToken: "refresh-token",
expiresAt: 1_900_000_000_000,
scopes: ["ChatMessage.Send", "offline_access"],
userPrincipalName: "user@example.com",
});
expect(loadDelegatedTokens()).toEqual({
accessToken: "access-token",
refreshToken: "refresh-token",
expiresAt: 1_900_000_000_000,
scopes: ["ChatMessage.Send", "offline_access"],
userPrincipalName: "user@example.com",
});
expect(
fs.existsSync(path.join(process.env.OPENCLAW_STATE_DIR ?? "", "msteams-delegated.json")),
).toBe(false);
});
it("rejects invalid delegated token payloads", () => {
expect(parseMSTeamsDelegatedTokens({ accessToken: "a" })).toBeNull();
expect(
parseMSTeamsDelegatedTokens({
accessToken: "a",
refreshToken: "r",
expiresAt: 1,
scopes: ["scope"],
}),
).toEqual({
accessToken: "a",
refreshToken: "r",
expiresAt: 1,
scopes: ["scope"],
});
});
});
describe("readAccessToken", () => {
it("reads string and object token forms", () => {
expect(readAccessToken("abc")).toBe("abc");

View File

@@ -1,6 +1,4 @@
import { readFileSync } from "node:fs";
import { basename, dirname } from "node:path";
import { privateFileStoreSync } from "openclaw/plugin-sdk/security-runtime";
import { createPluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import type { MSTeamsConfig } from "../runtime-api.js";
import type { MSTeamsDelegatedTokens } from "./oauth.shared.js";
import { refreshMSTeamsDelegatedTokens } from "./oauth.token.js";
@@ -9,7 +7,6 @@ import {
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "./secret-input.js";
import { resolveMSTeamsCredentialFilePath } from "./storage.js";
// ── Credential types ───────────────────────────────────────────────────────
@@ -142,24 +139,56 @@ export function resolveMSTeamsCredentials(cfg?: MSTeamsConfig): MSTeamsCredentia
// Delegated token storage / resolution
// ---------------------------------------------------------------------------
const DELEGATED_TOKEN_FILENAME = "msteams-delegated.json";
export const MSTEAMS_DELEGATED_TOKEN_FILENAME = "msteams-delegated.json";
export const MSTEAMS_DELEGATED_TOKEN_NAMESPACE = "delegated-tokens";
const MSTEAMS_PLUGIN_ID = "msteams";
const MSTEAMS_DELEGATED_TOKEN_KEY = "current";
function resolveDelegatedTokenPath(): string {
return resolveMSTeamsCredentialFilePath({ filename: DELEGATED_TOKEN_FILENAME });
const delegatedTokenStore = createPluginStateSyncKeyedStore<MSTeamsDelegatedTokens>(
MSTEAMS_PLUGIN_ID,
{
namespace: MSTEAMS_DELEGATED_TOKEN_NAMESPACE,
maxEntries: 8,
},
);
export function parseMSTeamsDelegatedTokens(value: unknown): MSTeamsDelegatedTokens | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const tokens = value as Partial<MSTeamsDelegatedTokens>;
if (
typeof tokens.accessToken !== "string" ||
!tokens.accessToken ||
typeof tokens.refreshToken !== "string" ||
!tokens.refreshToken ||
typeof tokens.expiresAt !== "number" ||
!Number.isFinite(tokens.expiresAt) ||
!Array.isArray(tokens.scopes) ||
tokens.scopes.some((scope) => typeof scope !== "string" || !scope)
) {
return null;
}
return {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
scopes: [...tokens.scopes],
...(typeof tokens.userPrincipalName === "string" && tokens.userPrincipalName
? { userPrincipalName: tokens.userPrincipalName }
: {}),
};
}
export function loadDelegatedTokens(): MSTeamsDelegatedTokens | undefined {
try {
const content = readFileSync(resolveDelegatedTokenPath(), "utf8");
return JSON.parse(content) as MSTeamsDelegatedTokens;
} catch {
return undefined;
}
return (
parseMSTeamsDelegatedTokens(delegatedTokenStore.lookup(MSTEAMS_DELEGATED_TOKEN_KEY)) ??
undefined
);
}
export function saveDelegatedTokens(tokens: MSTeamsDelegatedTokens): void {
const tokenPath = resolveDelegatedTokenPath();
privateFileStoreSync(dirname(tokenPath)).writeJson(basename(tokenPath), tokens);
delegatedTokenStore.register(MSTEAMS_DELEGATED_TOKEN_KEY, tokens);
}
export async function resolveDelegatedAccessToken(params: {

View File

@@ -98,6 +98,7 @@ const legacyStoreMarkers = [
pattern: /\bmsteams-pending-uploads\.json\b/u,
},
{ label: "Microsoft Teams SSO token JSON", pattern: /\bmsteams-sso-tokens\.json\b/u },
{ label: "Microsoft Teams delegated token JSON", pattern: /\bmsteams-delegated\.json\b/u },
{ label: "Microsoft Teams feedback learnings JSON", pattern: /\.learnings\.json\b/u },
{ label: "Matrix sync store JSON", pattern: /\bbot-storage\.json\b/u },
{ label: "Matrix storage metadata JSON", pattern: /\bstorage-meta\.json\b/u },