From 5fc91c2cb354d07626f9216d2ab053500907df49 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 16:46:50 +0100 Subject: [PATCH] refactor: store msteams delegated tokens in sqlite --- docs/refactor/database-first.md | 17 +++-- .../msteams/src/state-migrations.test.ts | 25 +++++++ extensions/msteams/src/state-migrations.ts | 36 ++++++++++ extensions/msteams/src/token.test.ts | 70 ++++++++++++++++++- extensions/msteams/src/token.ts | 59 ++++++++++++---- .../check-database-first-legacy-stores.mjs | 1 + 6 files changed, 185 insertions(+), 23 deletions(-) diff --git a/docs/refactor/database-first.md b/docs/refactor/database-first.md index 4574df18400..23e51bdc6aa 100644 --- a/docs/refactor/database-first.md +++ b/docs/refactor/database-first.md @@ -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` diff --git a/extensions/msteams/src/state-migrations.test.ts b/extensions/msteams/src/state-migrations.test.ts index dacac6bab85..a60ca0f7ca4 100644 --- a/extensions/msteams/src/state-migrations.test.ts +++ b/extensions/msteams/src/state-migrations.test.ts @@ -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"); diff --git a/extensions/msteams/src/state-migrations.ts b/extensions/msteams/src/state-migrations.ts index 02d0751417d..6f9143bb487 100644 --- a/extensions/msteams/src/state-migrations.ts +++ b/extensions/msteams/src/state-migrations.ts @@ -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({ diff --git a/extensions/msteams/src/token.test.ts b/extensions/msteams/src/token.test.ts index a806b758483..0291db724a9 100644 --- a/extensions/msteams/src/token.test.ts +++ b/extensions/msteams/src/token.test.ts @@ -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 = {}; @@ -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"); diff --git a/extensions/msteams/src/token.ts b/extensions/msteams/src/token.ts index 7ed0af1895a..8aadea087ae 100644 --- a/extensions/msteams/src/token.ts +++ b/extensions/msteams/src/token.ts @@ -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( + 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; + 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: { diff --git a/scripts/check-database-first-legacy-stores.mjs b/scripts/check-database-first-legacy-stores.mjs index 5e7fad0e2df..452ca807baa 100644 --- a/scripts/check-database-first-legacy-stores.mjs +++ b/scripts/check-database-first-legacy-stores.mjs @@ -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 },