From 1fd3bfbe64e5055633d92c968cd67b7ff3b2f533 Mon Sep 17 00:00:00 2001 From: skyzea1 <161649495+skyzea1@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:41:17 +0800 Subject: [PATCH] fix(providers): validate M365 Copilot web credentials (#5432) Integrated into release/v3.8.43 (drift-shed: cherry-picked the real change onto the release tip; stale-base drift dropped). --- open-sse/executors/copilot-m365-connection.ts | 41 +++++++++- src/lib/providers/validation.ts | 2 + src/lib/providers/validation/webProvidersB.ts | 76 +++++++++++++++++++ tests/unit/m365-connection-4042.test.ts | 21 +++++ .../provider-validation-specialty.test.ts | 31 ++++++++ 5 files changed, 170 insertions(+), 1 deletion(-) diff --git a/open-sse/executors/copilot-m365-connection.ts b/open-sse/executors/copilot-m365-connection.ts index 5c648f4b99..e5545de3f9 100644 --- a/open-sse/executors/copilot-m365-connection.ts +++ b/open-sse/executors/copilot-m365-connection.ts @@ -86,6 +86,38 @@ export function newChatSessionId(): string { return randomBytes(16).toString("hex"); } +function parsePastedCredential( + raw: string +): Partial> { + const value = raw.trim(); + const parts: Record = {}; + + for (const segment of value.split(/[;\n]/)) { + const separator = segment.indexOf("="); + if (separator <= 0) continue; + const key = segment.slice(0, separator).trim(); + const partValue = segment.slice(separator + 1).trim(); + if (key && partValue) parts[key] = partValue; + } + + if (/^wss:\/\/substrate\.office\.com\/m365Copilot\/Chathub\//i.test(value)) { + try { + const url = new URL(value); + parts.access_token ||= url.searchParams.get("access_token") || ""; + parts.chathubPath ||= decodeURIComponent( + url.pathname.split("/m365Copilot/Chathub/")[1] || "" + ); + } catch { + // Keep any key/value fields already parsed from the pasted text. + } + } + + return { + accessToken: parts.access_token || parts.accessToken, + chathubPath: parts.chathubPath || parts.userTenant, + }; +} + /** * Read the pasted credential bits. The individual access_token is opaque (JWE), * so it is consumed verbatim. The Chathub path (`user@tenant`) is pasted @@ -95,8 +127,14 @@ export function resolveConnectionParams( credentials: ProviderCredentials | undefined ): M365ConnectionParams | { error: string } { const psd = (credentials?.providerSpecificData ?? {}) as JsonRecord; + const parsedApiKey = + typeof credentials?.apiKey === "string" ? parsePastedCredential(credentials.apiKey) : {}; const accessToken = - (typeof credentials?.apiKey === "string" && credentials.apiKey) || + parsedApiKey.accessToken || + (typeof credentials?.apiKey === "string" && + credentials.apiKey && + !credentials.apiKey.includes("access_token=") && + credentials.apiKey) || (typeof psd.accessToken === "string" && psd.accessToken) || (typeof psd.access_token === "string" && psd.access_token) || ""; @@ -104,6 +142,7 @@ export function resolveConnectionParams( return { error: "Missing M365 Copilot access_token. Paste it as the provider credential." }; } const chathubPath = + parsedApiKey.chathubPath || (typeof psd.chathubPath === "string" && psd.chathubPath) || (typeof psd.userTenant === "string" && psd.userTenant) || ""; diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 1afdf2d454..de5218a6d5 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -38,6 +38,7 @@ import { validateAdaptaWebProvider, validateClaudeWebProvider, validateGeminiWebProvider, + validateCopilotM365WebProvider, validateCopilotWebProvider, validateT3WebProvider, validateJulesProvider, @@ -356,6 +357,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi "adapta-web": validateAdaptaWebProvider, "claude-web": validateClaudeWebProvider, "gemini-web": validateGeminiWebProvider, + "copilot-m365-web": validateCopilotM365WebProvider, "copilot-web": validateCopilotWebProvider, "t3-web": validateT3WebProvider, "azure-openai": validateAzureOpenAIProvider, diff --git a/src/lib/providers/validation/webProvidersB.ts b/src/lib/providers/validation/webProvidersB.ts index 62a721c0d1..e28e135c4c 100644 --- a/src/lib/providers/validation/webProvidersB.ts +++ b/src/lib/providers/validation/webProvidersB.ts @@ -291,6 +291,82 @@ export async function validateCopilotWebProvider({ apiKey, providerSpecificData } } +function extractM365CredentialParts(raw: string, providerSpecificData: Record) { + const text = raw.trim(); + const parts: Record = {}; + + for (const segment of text.split(/[;\n]/)) { + const index = segment.indexOf("="); + if (index <= 0) continue; + const key = segment.slice(0, index).trim(); + const value = segment.slice(index + 1).trim(); + if (key && value) parts[key] = value; + } + + if (/^wss:\/\/substrate\.office\.com\/m365Copilot\/Chathub\//i.test(text)) { + try { + const url = new URL(text); + parts.access_token ||= url.searchParams.get("access_token") || ""; + parts.chathubPath ||= decodeURIComponent( + url.pathname.split("/m365Copilot/Chathub/")[1] || "" + ); + } catch { + // Fall through to the structured key/value parser result. + } + } + + return { + accessToken: + parts.access_token || + parts.accessToken || + (typeof providerSpecificData.access_token === "string" + ? providerSpecificData.access_token + : "") || + (typeof providerSpecificData.accessToken === "string" ? providerSpecificData.accessToken : ""), + chathubPath: + parts.chathubPath || + parts.userTenant || + (typeof providerSpecificData.chathubPath === "string" + ? providerSpecificData.chathubPath + : "") || + (typeof providerSpecificData.userTenant === "string" ? providerSpecificData.userTenant : ""), + }; +} + +// ── Microsoft 365 Copilot Web token validator ── +export async function validateCopilotM365WebProvider({ + apiKey, + providerSpecificData = {}, +}: any) { + const { accessToken, chathubPath } = extractM365CredentialParts( + String(apiKey || ""), + providerSpecificData + ); + + if (!accessToken) { + return { + valid: false, + error: "Could not extract access_token from the Microsoft 365 Copilot credential", + }; + } + + if (!chathubPath || !chathubPath.includes("@")) { + return { + valid: false, + error: "Could not extract the account-specific Chathub path from the credential", + }; + } + + // The live provider uses a SignalR WebSocket. The generic web-cookie /models + // probe builds an invalid wss://.../models URL, so validation here confirms + // the captured credential shape and lets the executor perform the live check. + return { + valid: true, + error: null, + warning: "Credential format accepted. The session is verified when the provider sends a chat.", + }; +} + // ── t3.chat Web cookie validator ── export async function validateT3WebProvider({ apiKey, providerSpecificData = {} }: any) { try { diff --git a/tests/unit/m365-connection-4042.test.ts b/tests/unit/m365-connection-4042.test.ts index 3be24a9fdc..a6ce87515c 100644 --- a/tests/unit/m365-connection-4042.test.ts +++ b/tests/unit/m365-connection-4042.test.ts @@ -55,6 +55,27 @@ test("resolveConnectionParams accepts access_token in providerSpecificData and a assert.equal(p.host, "substrate.svc.cloud.microsoft"); }); +test("resolveConnectionParams parses the pasted OmniRoute credential line", () => { + const r = resolveConnectionParams({ + apiKey: "access_token=tok3; chathubPath=redacted-account@redacted-tenant", + }); + assert.ok(!("error" in r)); + const p = r as { chathubPath: string; accessToken: string }; + assert.equal(p.accessToken, "tok3"); + assert.equal(p.chathubPath, "redacted-account@redacted-tenant"); +}); + +test("resolveConnectionParams parses a pasted Chathub WebSocket URL", () => { + const r = resolveConnectionParams({ + apiKey: + "wss://substrate.office.com/m365Copilot/Chathub/redacted-account%40redacted-tenant?access_token=tok4&source=officeweb", + }); + assert.ok(!("error" in r)); + const p = r as { chathubPath: string; accessToken: string }; + assert.equal(p.accessToken, "tok4"); + assert.equal(p.chathubPath, "redacted-account@redacted-tenant"); +}); + // ── WS URL building ────────────────────────────────────────────────────── test("buildWsUrl targets the substrate Chathub with the individual-tier query", () => { diff --git a/tests/unit/provider-validation-specialty.test.ts b/tests/unit/provider-validation-specialty.test.ts index 9099a0d426..7f2550ea9c 100644 --- a/tests/unit/provider-validation-specialty.test.ts +++ b/tests/unit/provider-validation-specialty.test.ts @@ -2443,6 +2443,37 @@ test("copilot-web validator: empty input → paste prompt", async () => { assert.match(result.error || "", /Paste your access_token/i); }); +// ─── copilot-m365-web validator ────────────────────────────────────────────── + +test("copilot-m365-web validator: accepts pasted OmniRoute credential without /models probe", async () => { + globalThis.fetch = async () => { + throw new Error("should not fetch"); + }; + + const result = await validateProviderApiKey({ + provider: "copilot-m365-web", + apiKey: "access_token=tok; chathubPath=redacted-account@redacted-tenant", + }); + + assert.equal(result.valid, true); + assert.equal(result.error, null); + assert.match(result.warning || "", /verified when the provider sends a chat/i); +}); + +test("copilot-m365-web validator: requires chathubPath", async () => { + globalThis.fetch = async () => { + throw new Error("should not fetch"); + }; + + const result = await validateProviderApiKey({ + provider: "copilot-m365-web", + apiKey: "access_token=tok", + }); + + assert.equal(result.valid, false); + assert.match(result.error || "", /Chathub path/i); +}); + // ─── t3-web validator ──────────────────────────────────────────────────────── test("t3-web validator: valid cookies → valid", async () => {