Add persistent browser auth to connector canvas

Replace Azure CLI-only authentication with InteractiveBrowserCredential, protected sign-in lifecycle endpoints, and subscription refresh after sign-in. Persist the Azure Identity cache securely across extension reloads and align connector restart guidance with the GitHub Copilot app.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Alex Yang (DevDiv)
2026-07-20 13:04:18 -07:00
parent 9ae2bdc8b7
commit 2f258c7226
16 changed files with 945 additions and 258 deletions
+13 -10
View File
@@ -13,14 +13,15 @@ HTTP endpoint that the extension writes to the Copilot CLI config. The probe
uses the configured `X-API-Key`, follows `Mcp-Session-Id`, and accepts standard
JSON or SSE JSON-RPC responses.
The whole point: it runs with **Node and Azure CLI**. No Copilot app, no
canvas, no UI. Hand it to anyone (e.g. Arjun) and they can reproduce an MCP
server issue locally.
The whole point: it runs with **Node and a browser sign-in**. No Copilot app or
canvas is required. Hand it to anyone (e.g. Arjun) and they can reproduce an
MCP server issue locally.
## Prerequisites
1. **Azure CLI signed in with `az login`.** The harness asks Azure CLI for the
same short-lived ARM token as the extension.
1. **A browser for Microsoft Entra sign-in.** The harness opens the same
interactive Azure sign-in as the extension when its encrypted Azure Identity
session is not already available.
2. **A gateway already picked once.** The harness reads gateway coordinates from
`~/.copilot/extensions/connector-namespaces/artifacts/gateway-config.json`
(`{ subscriptionId, resourceGroup, gatewayName }`). Pick a gateway once in
@@ -52,10 +53,12 @@ node extensions/connector-namespaces/test/smoke.mjs --only=WorkIQMail,WorkIQShar
node extensions/connector-namespaces/test/smoke.mjs --limit=5 --open-consent
```
## One-time consent, then headless forever
## One-time connector consent
This is the key behavior. OAuth-backed servers (most of them) need a human to
consent **once** in a browser. The model:
OAuth-backed servers (most of them) need a human to consent **once** in a
browser. Azure ARM sign-in is restored from the operating system's encrypted
credential store when available; the one-time behavior below applies to the
connector's own consent. The model:
1. **First run** hits a server that needs consent → the harness prints a consent
URL and marks it `NEEDS_CONSENT`. It saves a pending record to
@@ -67,8 +70,8 @@ consent **once** in a browser. The model:
loopback page is just a redirect target and nothing is listening on it.
3. **Re-run the harness.** It sees the pending record, confirms the gateway
connection is now `Connected`, finishes the install (mints the API key,
writes the CLI entry), and probes it headless. From then on it's reused with
zero human interaction.
writes the CLI entry), and probes it headless. From then on the connector is
reused without repeating its consent.
So the server taxonomy is:
+31 -5
View File
@@ -6,8 +6,8 @@
// (install.mjs, catalog.mjs, armClient.mjs) and connects through the same native
// Streamable HTTP endpoint persisted for the Copilot CLI.
//
// Runs with `node` and a signed-in Azure CLI — no Copilot app required — so it
// can be handed to someone else to reproduce MCP issues. See README.md.
// Runs with `node` and an interactive browser sign-in — no Copilot app required
// — so it can be handed to someone else to reproduce MCP issues. See README.md.
//
// Usage:
// node extensions/connector-namespaces/test/smoke.mjs [options]
@@ -25,6 +25,7 @@ import { fileURLToPath } from "node:url";
import { loadSavedConfig } from "../state.mjs";
import { getToken } from "../armClient.mjs";
import { cancelSignIn, getSignInStatus, isAuthenticationRequiredError, startSignIn } from "../auth.mjs";
import { CATEGORY } from "../categories.mjs";
import {
installConnector,
@@ -115,6 +116,31 @@ function logLine(text) {
return redact(String(text)).replace(/[\r\n]+/g, " ");
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function signInToAzure() {
try {
return await getToken();
} catch (error) {
if (!isAuthenticationRequiredError(error)) throw error;
}
const started = startSignIn();
if (!started.ok || !started.sessionId) {
throw new Error(started.error || "Could not start Azure sign-in.");
}
console.log(`${C.dim}Complete Azure sign-in in the browser window...${C.reset}`);
for (let poll = 0; poll < 240; poll++) {
const status = getSignInStatus(started.sessionId);
if (status.status === "done") return getToken();
if (status.status === "error" || status.status === "cancelled" || status.status === "unknown") {
throw new Error(status.error || `Azure sign-in ${status.status}.`);
}
await sleep(2500);
}
cancelSignIn(started.sessionId);
throw new Error("Azure sign-in timed out.");
}
const C = {
reset: "\x1b[0m", dim: "\x1b[2m", bold: "\x1b[1m",
green: "\x1b[32m", red: "\x1b[31m", yellow: "\x1b[33m", cyan: "\x1b[36m",
@@ -124,7 +150,7 @@ const tick = (ok) => (ok ? `${C.green}PASS${C.reset}` : `${C.red}FAIL${C.reset}`
async function main() {
const opts = parseArgs(process.argv.slice(2));
// 1. Bootstrap: gateway coords + ARM token (fail fast).
// 1. Bootstrap: gateway coords + interactive ARM token (fail fast).
const config = loadSavedConfig();
if (!config?.subscriptionId || !config?.resourceGroup || !config?.gatewayName) {
console.error(`${C.red}No gateway config found.${C.reset} Expected ${join(ARTIFACTS_DIR, "gateway-config.json")}.`);
@@ -132,9 +158,9 @@ async function main() {
process.exit(2);
}
try {
await getToken();
await signInToAzure();
} catch (err) {
console.error(`${C.red}Could not get an ARM token.${C.reset} Sign in to Azure when the browser opens.`);
console.error(`${C.red}Could not get an ARM token.${C.reset}`);
console.error(String(err.message || err).slice(0, 300));
process.exit(2);
}