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).
This commit is contained in:
skyzea1
2026-06-30 21:41:17 +08:00
committed by GitHub
parent 802f1d42c5
commit 1fd3bfbe64
5 changed files with 170 additions and 1 deletions

View File

@@ -86,6 +86,38 @@ export function newChatSessionId(): string {
return randomBytes(16).toString("hex");
}
function parsePastedCredential(
raw: string
): Partial<Pick<M365ConnectionParams, "accessToken" | "chathubPath">> {
const value = raw.trim();
const parts: Record<string, string> = {};
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) ||
"";

View File

@@ -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,

View File

@@ -291,6 +291,82 @@ export async function validateCopilotWebProvider({ apiKey, providerSpecificData
}
}
function extractM365CredentialParts(raw: string, providerSpecificData: Record<string, unknown>) {
const text = raw.trim();
const parts: Record<string, string> = {};
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 {

View File

@@ -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", () => {

View File

@@ -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 () => {