From e18018d4d783bc69696ce89abb8ef61a58487ef9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:12:00 +0000 Subject: [PATCH] chore: publish from main --- .github/plugin/marketplace.json | 4 +- .../.github/plugin/plugin.json | 4 +- .../extensions/connector-namespaces/README.md | 126 +++---- .../connector-namespaces/armClient.mjs | 123 +------ .../extensions/connector-namespaces/auth.mjs | 292 +++++++++++++++ .../connector-namespaces/auth.test.mjs | 339 ++++++++++++++++++ .../connector-namespaces/extension.mjs | 2 +- .../install.reauth.test.mjs | 24 +- .../connector-namespaces/package.json | 6 +- .../connector-namespaces/preview/README.md | 2 +- .../connector-namespaces/renderer.mjs | 235 ++++++++++-- .../connector-namespaces/renderer.test.mjs | 35 +- .../review-fixes.test.mjs | 60 +--- .../connector-namespaces/server.mjs | 88 +++-- .../connector-namespaces/server.test.mjs | 72 +++- .../connector-namespaces/test/README.md | 36 +- .../connector-namespaces/test/smoke.mjs | 36 +- 17 files changed, 1169 insertions(+), 315 deletions(-) create mode 100644 extensions/connector-namespaces/extensions/connector-namespaces/auth.mjs create mode 100644 extensions/connector-namespaces/extensions/connector-namespaces/auth.test.mjs diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 9fb079f5..59b71c0d 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -244,8 +244,8 @@ { "name": "connector-namespaces", "source": "extensions/connector-namespaces", - "description": "Browse, connect, and open MCP connectors from an Azure Connector Namespace.", - "version": "1.1.2" + "description": "Interactive GitHub Copilot canvas for discovering, connecting, and managing hosted MCP servers from Azure Connector Namespace.", + "version": "1.2.0" }, { "name": "context-engineering", diff --git a/extensions/connector-namespaces/.github/plugin/plugin.json b/extensions/connector-namespaces/.github/plugin/plugin.json index 1a90eebf..0c5c077c 100644 --- a/extensions/connector-namespaces/.github/plugin/plugin.json +++ b/extensions/connector-namespaces/.github/plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "connector-namespaces", - "description": "Browse, connect, and open MCP connectors from an Azure Connector Namespace.", - "version": "1.1.2", + "description": "Interactive GitHub Copilot canvas for discovering, connecting, and managing hosted MCP servers from Azure Connector Namespace.", + "version": "1.2.0", "author": { "name": "Alex Yang", "url": "https://github.com/alexyaang" diff --git a/extensions/connector-namespaces/extensions/connector-namespaces/README.md b/extensions/connector-namespaces/extensions/connector-namespaces/README.md index f7346247..43e7bc99 100644 --- a/extensions/connector-namespaces/extensions/connector-namespaces/README.md +++ b/extensions/connector-namespaces/extensions/connector-namespaces/README.md @@ -1,84 +1,84 @@ -# MCP Connectors — Copilot CLI Canvas Extension +# MCP Connectors -A GitHub Copilot CLI **canvas extension** that lets you browse and add MCP -connectors from an Azure **Connector Namespace** directly inside a Copilot CLI -session. Search by name or category, sign in to a connector, then restart the -session to make its tools available to the agent. +A GitHub Copilot app canvas extension for discovering and connecting hosted MCP +servers from [Azure Connector Namespace](https://learn.microsoft.com/en-us/azure/connector-namespace/connector-namespace-overview). +It brings the Microsoft and partner connector catalog, guided browser sign-in, +and connected-server management into the Copilot side panel. -> The canvas talks to public Azure Resource Manager (`management.azure.com`) -> using the signed-in Azure CLI account. The extension does not register its own -> Entra application or persist Azure credentials. +## Features -## Prerequisites - -- **GitHub Copilot CLI** (the host that loads canvas extensions). -- **Azure CLI**, signed in with `az login`. The extension asks Azure CLI for a - short-lived ARM access token and refreshes it through the same broker. -- **An Azure subscription with a Connector Namespace** — resource type - `Microsoft.Web/connectorGateways` (API version `2026-05-01-preview`). This is - a preview resource provider; you must have access to it for the catalog to - load. Without it the extension installs fine but has nothing to show. +- **Connector catalog** - browse and search Microsoft and partner MCP servers + available in your namespace. +- **Guided Azure setup** - sign in from the canvas, then choose a subscription + and Connector Namespace. +- **Browser-based connection flow** - complete each connector's authentication + or consent without leaving the setup experience. +- **My MCPs** - see which servers are connected and ready to add to Copilot. +- **Namespace playground** - open any connected server in the Connector + Namespace playground with **Sandbox**. +- **Persistent setup** - retain the selected namespace and restore Azure sign-in + securely across app restarts. ## Install -Install it from the public Awesome Copilot repository: +Open the GitHub Copilot app, go to **Settings > Plugins**, search for +`connector-namespaces`, and select **Install**. -``` -install_extension https://github.com/github/awesome-copilot/tree/main/extensions/connector-namespaces -``` +You can also open the +[MCP Connectors gallery page](https://awesome-copilot.github.com/extension/connector-namespaces/) +and select **Install in GitHub Copilot app**. -For a reproducible install, swap `main` for a reviewed commit SHA from this -repository. +## Requirements -The destination **scope** is chosen at install time: +- Access to an Azure subscription with a Connector Namespace. If you do not + have one, follow the + [Connector Namespace creation guide](https://learn.microsoft.com/en-us/azure/connector-namespace/create-connector-namespace). +- Permission to view the namespace and create its connections and hosted MCP + server configurations. +- A browser for Microsoft Entra sign-in and connector consent. +- An operating-system secure credential store. Windows and macOS provide one by + default. Linux and WSL require a Secret Service-compatible keyring, such as + GNOME Keyring, with `libsecret` available. Unencrypted token storage is + intentionally disabled. -- **user** (default) — installs globally for you at - `$COPILOT_HOME/extensions/connector-namespaces/`. The usual choice for a - personal tool. -- **project** — installs into the current repo. -- **session** — scoped to a single CLI session. +Connector Namespace is currently an Azure preview service and availability can +vary by region. ## Usage -1. Open the **MCP Connectors** canvas from Copilot CLI. -2. The canvas loads subscriptions from your signed-in Azure CLI account. Pick an - Azure **subscription** and a **Connector Namespace**. The choice is saved for - future sessions (change it any time via **Change namespace**). -3. Browse or filter the connector catalog, then **Connect**. A browser tab - opens for Microsoft sign-in; complete it and the canvas updates on its own. -4. Connected connectors move into **My MCPs**. Use **Sandbox** on a tile to open - that server directly in the namespace MCP playground. -5. Restart the Copilot CLI session so the agent can load the connected tools. +1. Open the **MCP Connectors** canvas in the GitHub Copilot app. +2. Select **Sign in to Azure** and complete Microsoft Entra authentication in + your browser. +3. Choose an Azure subscription and Connector Namespace. +4. Browse or search the catalog, then select **Connect** on an MCP server. +5. Complete the connector's sign-in or consent flow when prompted. +6. Confirm the server appears under **My MCPs**. +7. Restart the GitHub Copilot app so the new tools become available to the + agent. -The extension registers the native `connector_namespaces_open_playground` tool, -so GitHub Copilot can open a named connector from **My MCPs** without installing -an additional Agent Skill. +Use **Sandbox** on a connected server to inspect it in the Connector Namespace +playground. Use **Change namespace** to switch subscriptions or namespaces. -## How it works +## Authentication and privacy -- `extension.mjs` — entry point; declares the canvas, `open_sandbox` action, and - native `connector_namespaces_open_playground` tool. -- `server.mjs` — a loopback HTTP server (bound to `127.0.0.1` only) that serves - the canvas UI and the JSON/OAuth endpoints the iframe calls. -- `armClient.mjs` — thin ARM client (token brokered by Azure CLI, public ARM - base only, SSRF-guarded path segments). -- `catalog.mjs` — fetches and curates the connector list for a namespace. -- `install.mjs` — the connect/install pipeline (managed-API connection, consent, - rollback on cancel, and native HTTPS MCP config registration). -- `renderer.mjs` — all canvas HTML/CSS/client JS. -- `sandbox.mjs` — builds namespace playground links and resolves named My MCPs. -- `state.mjs` — saved namespace and connector state. +Azure sign-in and connector sign-in are separate: -## Privacy & security +- **Azure sign-in** lets the canvas discover and manage Connector Namespace + resources. Access and refresh tokens are stored in the operating system's + encrypted credential store. To select that encrypted cache entry after an app + restart, the extension separately saves a non-secret authentication record + containing the authority, client ID, account ID, tenant ID, and username under + `~/.copilot/extensions/connector-namespaces/artifacts/azure-auth-record.json`, + with user-only permissions where supported. Raw tokens are never written to + extension files. +- **Connector sign-in** grants an individual MCP server access to its backing + service. The resulting connection is managed by Connector Namespace. -- ARM tokens come from `az account get-access-token`, stay in process memory, - and are never logged or written by the extension. Azure CLI owns sign-in and - credential storage. -- All servers bind to loopback (`127.0.0.1`) and are never exposed externally. -- ARM requests go only to `https://management.azure.com/`; path segments are - validated to prevent SSRF-style host smuggling. -- The minted gateway API key is stored in the selected Copilot MCP config and - sent to its validated HTTPS endpoint as the `X-API-Key` header. +The canvas serves its interface from loopback only (`127.0.0.1`). Azure +management requests are restricted to `https://management.azure.com/`. +The gateway API key that lets Copilot reach a connected server is stored in the +user-scoped GitHub Copilot MCP configuration and sent only to that server's +configured HTTPS endpoint. ## License diff --git a/extensions/connector-namespaces/extensions/connector-namespaces/armClient.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/armClient.mjs index dc0f9fc4..c4ef7f4a 100644 --- a/extensions/connector-namespaces/extensions/connector-namespaces/armClient.mjs +++ b/extensions/connector-namespaces/extensions/connector-namespaces/armClient.mjs @@ -1,62 +1,17 @@ -// ARM API client — fetches real connector data with Azure CLI credentials. +// ARM API client — fetches real connector data with interactive Azure credentials. -import { exec, execFile } from "node:child_process"; import { constants as fsConstants, promises as fs } from "node:fs"; -import { homedir, platform } from "node:os"; -import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from "node:path"; -import { promisify } from "node:util"; +import { platform } from "node:os"; +import { basename, isAbsolute, join, resolve, sep } from "node:path"; +import { getToken } from "./auth.mjs"; + +export { getToken }; const API_VERSION = "2026-05-01-preview"; const RG_API_VERSION = "2021-04-01"; const MSI_API_VERSION = "2023-01-31"; const SUBS_API_VERSION = "2020-01-01"; -const execFileAsync = promisify(execFile); -const execAsync = promisify(exec); -const ARM_RESOURCE = "https://management.azure.com/"; -const EXPIRY_SKEW_MS = 5 * 60 * 1000; -const LEGACY_AUTH_CACHE = join( - process.env.COPILOT_HOME || join(homedir(), ".copilot"), - "extensions", - "connector-namespaces", - "artifacts", - "auth-cache.json", -); - -let s_auth = null; // { token, expiresAt } -let s_authInFlight = null; -let s_legacyAuthCacheRemoved = false; - -export function parseAzureCliToken(stdout) { - let data; - try { - data = JSON.parse(stdout); - } catch { - throw new Error("Azure CLI returned invalid token JSON."); - } - const token = data?.accessToken; - const epochSeconds = Number(data?.expires_on); - const expiresAt = Number.isFinite(epochSeconds) && epochSeconds > 0 - ? epochSeconds * 1000 - : Date.parse(data?.expiresOn); - if (typeof token !== "string" || token.length === 0 || !Number.isFinite(expiresAt)) { - throw new Error("Azure CLI returned an incomplete ARM token."); - } - return { token, expiresAt }; -} - -async function removeLegacyAuthCache() { - if (s_legacyAuthCacheRemoved) return; - try { - await fs.unlink(LEGACY_AUTH_CACHE); - } catch (error) { - if (error?.code !== "ENOENT") { - throw new Error(`Could not remove the legacy connector credential cache at ${LEGACY_AUTH_CACHE}: ${error.message}`); - } - } - s_legacyAuthCacheRemoved = true; -} - function windowsSystemExecutable(name) { const systemRoot = process.env.SystemRoot; if (systemRoot && isAbsolute(systemRoot)) return join(systemRoot, "System32", name); @@ -92,29 +47,6 @@ async function trustedExecutablePath(path, expectedName, workspaceRoot = process return candidate; } -async function resolveWindowsAzureCli() { - const trustedCwd = homedir(); - const { stdout } = await execFileAsync( - windowsSystemExecutable("where.exe"), - ["az.cmd"], - { cwd: trustedCwd, encoding: "utf8", windowsHide: true, timeout: 10_000, maxBuffer: 64 * 1024 }, - ); - for (const path of stdout.split(/\r?\n/).map((line) => line.trim())) { - if (/[%]/.test(path)) continue; - const candidate = await trustedExecutablePath(path, "az.cmd"); - if (candidate) return candidate; - } - throw new Error("Azure CLI was not found outside the current workspace."); -} - -export async function resolvePosixAzureCli(pathValue = process.env.PATH || "", workspaceRoot = process.cwd()) { - for (const directory of pathValue.split(delimiter)) { - const candidate = await trustedExecutablePath(resolve(directory || workspaceRoot, "az"), "az", workspaceRoot); - if (candidate) return candidate; - } - throw new Error("Azure CLI was not found outside the current workspace."); -} - export async function resolveSystemExecutable(name, workspaceRoot = process.cwd()) { const candidates = platform() === "win32" ? [windowsSystemExecutable(name)] @@ -126,41 +58,6 @@ export async function resolveSystemExecutable(name, workspaceRoot = process.cwd( throw new Error(`Could not resolve the trusted system executable ${name}.`); } -async function acquireToken() { - await removeLegacyAuthCache(); - try { - const windows = platform() === "win32"; - const azureCli = windows ? await resolveWindowsAzureCli() : await resolvePosixAzureCli(); - const options = { cwd: homedir(), encoding: "utf8", windowsHide: true, timeout: 60_000, maxBuffer: 1024 * 1024 }; - const { stdout } = windows - ? await execAsync( - `"${azureCli}" account get-access-token --resource https://management.azure.com/ --output json --only-show-errors`, - { ...options, shell: windowsSystemExecutable("cmd.exe") }, - ) - : await execFileAsync( - azureCli, - ["account", "get-access-token", "--resource", ARM_RESOURCE, "--output", "json", "--only-show-errors"], - options, - ); - s_auth = parseAzureCliToken(stdout); - return s_auth.token; - } catch (error) { - const detail = String(error?.stderr || error?.message || "").trim(); - throw new Error( - `Azure CLI authentication failed. Install Azure CLI and run "az login" before opening the canvas.${detail ? ` ${detail}` : ""}`, - ); - } -} - -export async function getToken() { - if (s_auth && s_auth.expiresAt - EXPIRY_SKEW_MS > Date.now()) return s_auth.token; - if (s_authInFlight) return s_authInFlight; - s_authInFlight = acquireToken().finally(() => { - s_authInFlight = null; - }); - return s_authInFlight; -} - /** * List all enabled Azure subscriptions the user has access to. */ @@ -170,9 +67,13 @@ export async function getToken() { let s_subsCache = null; // { subs, expiresAt } const SUBS_TTL_MS = 30 * 60 * 1000; -export async function listSubscriptions() { +export function invalidateSubscriptionsCache() { + s_subsCache = null; +} + +export async function listSubscriptions({ forceRefresh = false } = {}) { const now = Date.now(); - if (s_subsCache && s_subsCache.expiresAt > now) return s_subsCache.subs; + if (!forceRefresh && s_subsCache && s_subsCache.expiresAt > now) return s_subsCache.subs; const token = await getToken(); const url = `https://management.azure.com/subscriptions?api-version=${SUBS_API_VERSION}`; const raw = await paginateAll(url, token); diff --git a/extensions/connector-namespaces/extensions/connector-namespaces/auth.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/auth.mjs new file mode 100644 index 00000000..0b72597b --- /dev/null +++ b/extensions/connector-namespaces/extensions/connector-namespaces/auth.mjs @@ -0,0 +1,292 @@ +import { randomUUID } from "node:crypto"; +import { promises as fs } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + deserializeAuthenticationRecord, + InteractiveBrowserCredential, + serializeAuthenticationRecord, + useIdentityPlugin, +} from "@azure/identity"; +import { cachePersistencePlugin } from "@azure/identity-cache-persistence"; + +export const ARM_SCOPE = "https://management.azure.com/.default"; +export const TOKEN_CACHE_NAME = "github-copilot-connector-namespaces"; + +const TOKEN_EXPIRY_SKEW_MS = 5 * 60 * 1000; +const SIGN_IN_SESSION_TTL_MS = 10 * 60 * 1000; +const AUTH_STORAGE_DIR = join( + process.env.COPILOT_HOME || join(homedir(), ".copilot"), + "extensions", + "connector-namespaces", + "artifacts", +); +const AUTH_RECORD_FILE = join(AUTH_STORAGE_DIR, "azure-auth-record.json"); +const LEGACY_AUTH_CACHE = join(AUTH_STORAGE_DIR, "auth-cache.json"); + +let legacyAuthCacheRemoved = false; + +useIdentityPlugin(cachePersistencePlugin); + +export async function loadAuthenticationRecord({ + readFile = fs.readFile, + deserialize = deserializeAuthenticationRecord, + authRecordFile = AUTH_RECORD_FILE, +} = {}) { + let serialized; + try { + serialized = await readFile(authRecordFile, "utf-8"); + } catch (error) { + if (error?.code === "ENOENT") return undefined; + throw error; + } + try { + return deserialize(serialized); + } catch { + return undefined; + } +} + +async function saveAuthenticationRecord(record) { + await fs.mkdir(AUTH_STORAGE_DIR, { recursive: true, mode: 0o700 }); + await fs.chmod(AUTH_STORAGE_DIR, 0o700); + await fs.writeFile( + AUTH_RECORD_FILE, + serializeAuthenticationRecord(record), + { encoding: "utf-8", mode: 0o600 }, + ); + await fs.chmod(AUTH_RECORD_FILE, 0o600); +} + +async function removeLegacyAuthCache() { + if (legacyAuthCacheRemoved) return; + try { + await fs.unlink(LEGACY_AUTH_CACHE); + } catch (error) { + if (error?.code !== "ENOENT") { + throw new Error(`Could not remove the legacy connector credential cache at ${LEGACY_AUTH_CACHE}: ${error.message}`); + } + } + legacyAuthCacheRemoved = true; +} + +export class ConnectorAuthenticationRequiredError extends Error { + constructor(message = "Sign in to Azure to continue.", options) { + super(message, options); + this.name = "ConnectorAuthenticationRequiredError"; + this.code = "authentication_required"; + } +} + +export function isAuthenticationRequiredError(error) { + return error instanceof ConnectorAuthenticationRequiredError + || error?.code === "authentication_required" + || error?.name === "AuthenticationRequiredError"; +} + +function credentialFactory(options) { + return new InteractiveBrowserCredential(options); +} + +function hasUsableToken(accessToken, now) { + return !!accessToken?.token + && Number.isFinite(accessToken.expiresOnTimestamp) + && accessToken.expiresOnTimestamp - TOKEN_EXPIRY_SKEW_MS > now; +} + +function errorDetail(error) { + return String(error?.message || error || "Azure sign-in failed.").slice(0, 400); +} + +export class InteractiveAuthBroker { + constructor({ + createCredential = credentialFactory, + createSessionId = randomUUID, + cleanupLegacyCredentials = async () => {}, + loadAuthRecord = async () => undefined, + saveAuthRecord = async () => {}, + now = Date.now, + scope = ARM_SCOPE, + tokenCacheName = TOKEN_CACHE_NAME, + } = {}) { + this.createCredential = createCredential; + this.createSessionId = createSessionId; + this.cleanupLegacyCredentials = cleanupLegacyCredentials; + this.loadAuthRecord = loadAuthRecord; + this.saveAuthRecord = saveAuthRecord; + this.now = now; + this.scope = scope; + this.tokenCacheName = tokenCacheName; + this.credential = null; + this.accessToken = null; + this.cleanupInFlight = null; + this.tokenInFlight = null; + this.sessions = new Map(); + } + + createInteractiveCredential(authenticationRecord) { + return this.createCredential({ + redirectUri: "http://localhost", + disableAutomaticAuthentication: true, + tokenCachePersistenceOptions: { + enabled: true, + name: this.tokenCacheName, + }, + ...(authenticationRecord ? { authenticationRecord } : {}), + }); + } + + ensureLegacyCredentialsRemoved() { + if (!this.cleanupInFlight) { + const cleanup = Promise.resolve() + .then(() => this.cleanupLegacyCredentials()) + .catch((error) => { + if (this.cleanupInFlight === cleanup) this.cleanupInFlight = null; + throw error; + }); + this.cleanupInFlight = cleanup; + } + return this.cleanupInFlight; + } + + pruneSessions() { + const cutoff = this.now() - SIGN_IN_SESSION_TTL_MS; + for (const [sessionId, session] of this.sessions) { + if (session.createdAt >= cutoff) continue; + session.status = "cancelled"; + session.abortController.abort(); + this.sessions.delete(sessionId); + } + } + + startSignIn() { + this.pruneSessions(); + const sessionId = this.createSessionId(); + const abortController = new AbortController(); + let credential; + try { + credential = this.createInteractiveCredential(); + } catch (error) { + return { ok: false, reason: "identity_unavailable", error: errorDetail(error) }; + } + + const session = { + abortController, + createdAt: this.now(), + error: "", + status: "pending", + }; + this.sessions.set(sessionId, session); + + session.promise = Promise.resolve() + .then(async () => { + await this.ensureLegacyCredentialsRemoved(); + const authenticationRecord = await credential.authenticate( + this.scope, + { abortSignal: abortController.signal }, + ); + if (!authenticationRecord) { + throw new Error("Azure identity did not return an authentication record."); + } + await this.saveAuthRecord(authenticationRecord); + const accessToken = await credential.getToken(this.scope, { abortSignal: abortController.signal }); + if (!accessToken?.token || !Number.isFinite(accessToken.expiresOnTimestamp)) { + throw new Error("Azure identity returned an incomplete ARM access token."); + } + if (session.status !== "pending" || this.sessions.get(sessionId) !== session) return; + this.credential = credential; + this.accessToken = accessToken; + session.status = "done"; + }) + .catch((error) => { + if (session.status !== "pending") return; + session.status = abortController.signal.aborted ? "cancelled" : "error"; + if (session.status === "error") session.error = errorDetail(error); + }); + + return { ok: true, sessionId, mode: "interactive" }; + } + + getSignInStatus(sessionId) { + this.pruneSessions(); + const session = this.sessions.get(sessionId); + if (!session) return { ok: false, status: "unknown" }; + if (session.status === "pending") return { ok: true, status: "pending", mode: "interactive" }; + + if (session.status === "done") return { ok: true, status: "done" }; + if (session.status === "cancelled") return { ok: true, status: "cancelled" }; + return { ok: false, status: "error", error: session.error || "Azure sign-in failed." }; + } + + cancelSignIn(sessionId) { + const session = this.sessions.get(sessionId); + if (!session || session.status !== "pending") return { ok: true }; + session.status = "cancelled"; + session.abortController.abort(); + return { ok: true }; + } + + async getToken() { + await this.ensureLegacyCredentialsRemoved(); + if (hasUsableToken(this.accessToken, this.now())) return this.accessToken.token; + if (this.tokenInFlight) return this.tokenInFlight; + + const request = (async () => { + let credential = this.credential; + if (!credential) { + try { + const authenticationRecord = await this.loadAuthRecord(); + if (hasUsableToken(this.accessToken, this.now())) return this.accessToken.token; + credential = this.credential; + if (!credential) { + credential = this.createInteractiveCredential(authenticationRecord); + this.credential = credential; + } + } catch (error) { + if (isAuthenticationRequiredError(error)) { + throw new ConnectorAuthenticationRequiredError( + "Sign in to Azure to continue.", + { cause: error }, + ); + } + throw error; + } + } + try { + const accessToken = await credential.getToken(this.scope); + if (!accessToken?.token || !Number.isFinite(accessToken.expiresOnTimestamp)) { + throw new Error("Azure identity returned an incomplete ARM access token."); + } + this.accessToken = accessToken; + return accessToken.token; + } catch (error) { + if (!isAuthenticationRequiredError(error)) throw error; + if (this.credential === credential) { + this.credential = null; + this.accessToken = null; + } + throw new ConnectorAuthenticationRequiredError( + "Sign in to Azure to continue.", + { cause: error }, + ); + } + })(); + this.tokenInFlight = request; + try { + return await request; + } finally { + if (this.tokenInFlight === request) this.tokenInFlight = null; + } + } +} + +export const interactiveAuth = new InteractiveAuthBroker({ + cleanupLegacyCredentials: removeLegacyAuthCache, + loadAuthRecord: loadAuthenticationRecord, + saveAuthRecord: saveAuthenticationRecord, +}); + +export const startSignIn = () => interactiveAuth.startSignIn(); +export const getSignInStatus = (sessionId) => interactiveAuth.getSignInStatus(sessionId); +export const cancelSignIn = (sessionId) => interactiveAuth.cancelSignIn(sessionId); +export const getToken = () => interactiveAuth.getToken(); diff --git a/extensions/connector-namespaces/extensions/connector-namespaces/auth.test.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/auth.test.mjs new file mode 100644 index 00000000..43356644 --- /dev/null +++ b/extensions/connector-namespaces/extensions/connector-namespaces/auth.test.mjs @@ -0,0 +1,339 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + ConnectorAuthenticationRequiredError, + InteractiveAuthBroker, + loadAuthenticationRecord, + TOKEN_CACHE_NAME, +} from "./auth.mjs"; + +function accessToken(token = "token", expiresOnTimestamp = 2_000_000_000_000) { + return { token, expiresOnTimestamp }; +} + +function authenticationRecord(username = "user@example.com") { + return { + authority: "login.microsoftonline.com", + homeAccountId: "home-account", + clientId: "client-id", + tenantId: "tenant-id", + username, + }; +} + +test("ARM token requests require an explicit browser sign-in", async () => { + let credentialOptions; + const broker = new InteractiveAuthBroker({ + createCredential(options) { + credentialOptions = options; + return { + async getToken() { + const error = new Error("No cached account found."); + error.name = "AuthenticationRequiredError"; + throw error; + }, + }; + }, + loadAuthRecord: async () => undefined, + }); + + await assert.rejects( + broker.getToken(), + (error) => error instanceof ConnectorAuthenticationRequiredError + && error.code === "authentication_required", + ); + assert.deepEqual(credentialOptions.tokenCachePersistenceOptions, { + enabled: true, + name: TOKEN_CACHE_NAME, + }); +}); + +test("a malformed authentication record falls back to browser sign-in", async () => { + assert.equal( + await loadAuthenticationRecord({ + readFile: async () => "{malformed-json", + }), + undefined, + ); +}); + +test("authentication record read failures remain operational errors", async () => { + const readError = Object.assign(new Error("authentication record is unreadable"), { code: "EACCES" }); + await assert.rejects( + loadAuthenticationRecord({ + readFile: async () => { throw readError; }, + }), + (error) => error === readError, + ); +}); + +test("interactive sign-in reports pending then done and caches the ARM token", async () => { + let credentialOptions; + let authenticateOptions; + const credential = { + async authenticate(scope, options) { + assert.equal(scope, "https://management.azure.com/.default"); + authenticateOptions = options; + return authenticationRecord(); + }, + async getToken(scope, options) { + assert.equal(scope, "https://management.azure.com/.default"); + assert.equal(options.abortSignal, authenticateOptions.abortSignal); + return accessToken(); + }, + }; + const broker = new InteractiveAuthBroker({ + createCredential(options) { + credentialOptions = options; + return credential; + }, + createSessionId: () => "signin-session", + saveAuthRecord: async (record) => assert.deepEqual(record, authenticationRecord()), + now: () => 1_000, + }); + + const started = broker.startSignIn(); + assert.deepEqual(started, { + ok: true, + sessionId: "signin-session", + mode: "interactive", + }); + assert.deepEqual(broker.getSignInStatus(started.sessionId), { + ok: true, + status: "pending", + mode: "interactive", + }); + + await broker.sessions.get(started.sessionId).promise; + + assert.deepEqual(credentialOptions, { + redirectUri: "http://localhost", + disableAutomaticAuthentication: true, + tokenCachePersistenceOptions: { + enabled: true, + name: TOKEN_CACHE_NAME, + }, + }); + assert.equal(authenticateOptions.abortSignal.aborted, false); + assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "done" }); + assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "done" }); + assert.deepEqual(broker.cancelSignIn(started.sessionId), { ok: true }); + assert.equal(authenticateOptions.abortSignal.aborted, false); + assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "done" }); + assert.equal(await broker.getToken(), "token"); +}); + +test("a new broker restores the persisted credential without reopening the browser", async () => { + const cache = { record: null, token: null }; + let authenticateCalls = 0; + const createCredential = (options) => { + assert.deepEqual(options.tokenCachePersistenceOptions, { + enabled: true, + name: TOKEN_CACHE_NAME, + }); + return { + async authenticate() { + authenticateCalls++; + cache.token = accessToken("persisted-token"); + return authenticationRecord(); + }, + async getToken() { + if (cache.token && options.authenticationRecord) return cache.token; + const error = new Error("No cached account found."); + error.name = "AuthenticationRequiredError"; + throw error; + }, + }; + }; + const signedInBroker = new InteractiveAuthBroker({ + createCredential, + createSessionId: () => "persist-session", + saveAuthRecord: async (record) => { cache.record = record; }, + now: () => 1_000, + }); + const started = signedInBroker.startSignIn(); + await signedInBroker.sessions.get(started.sessionId).promise; + assert.equal(authenticateCalls, 1); + + const restartedBroker = new InteractiveAuthBroker({ + createCredential, + loadAuthRecord: async () => cache.record, + now: () => 1_000, + }); + assert.equal(await restartedBroker.getToken(), "persisted-token"); + assert.equal(authenticateCalls, 1); +}); + +test("concurrent first-time token requests share credential initialization and acquisition", async () => { + let releaseAuthenticationRecord; + const authenticationRecordReady = new Promise((resolve) => { + releaseAuthenticationRecord = resolve; + }); + let loadAuthRecordCalls = 0; + let createCredentialCalls = 0; + let tokenCalls = 0; + const broker = new InteractiveAuthBroker({ + async loadAuthRecord() { + loadAuthRecordCalls++; + await authenticationRecordReady; + return authenticationRecord(); + }, + createCredential: () => { + createCredentialCalls++; + return { + async getToken() { + tokenCalls++; + return accessToken("shared-token"); + }, + }; + }, + }); + + const firstRequest = broker.getToken(); + const secondRequest = broker.getToken(); + await new Promise((resolve) => setImmediate(resolve)); + const loadsBeforeRelease = loadAuthRecordCalls; + releaseAuthenticationRecord(); + + assert.deepEqual( + await Promise.all([firstRequest, secondRequest]), + ["shared-token", "shared-token"], + ); + assert.equal(loadsBeforeRelease, 1); + assert.equal(loadAuthRecordCalls, 1); + assert.equal(createCredentialCalls, 1); + assert.equal(tokenCalls, 1); +}); + +test("cancelling sign-in aborts the credential request", async () => { + let abortSignal; + const credential = { + authenticate(_scope, options) { + abortSignal = options.abortSignal; + return new Promise((resolve, reject) => { + options.abortSignal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + }); + }, + async getToken() { + const error = new Error("No cached account found."); + error.name = "AuthenticationRequiredError"; + throw error; + }, + }; + const broker = new InteractiveAuthBroker({ + createCredential: () => credential, + createSessionId: () => "cancel-session", + loadAuthRecord: async () => undefined, + now: () => 1_000, + }); + + const started = broker.startSignIn(); + const pending = broker.sessions.get(started.sessionId).promise; + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(abortSignal.aborted, false); + + assert.deepEqual(broker.cancelSignIn(started.sessionId), { ok: true }); + await pending; + + assert.equal(abortSignal.aborted, true); + assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "cancelled" }); + assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "cancelled" }); + await assert.rejects(broker.getToken(), ConnectorAuthenticationRequiredError); +}); + +test("sign-in failures are surfaced through the status endpoint contract", async () => { + const broker = new InteractiveAuthBroker({ + createCredential: () => ({ + async authenticate() { + throw new Error("browser launch failed"); + }, + async getToken() { + throw new Error("unreachable"); + }, + }), + createSessionId: () => "failed-session", + now: () => 1_000, + }); + + const started = broker.startSignIn(); + await broker.sessions.get(started.sessionId).promise; + + assert.deepEqual(broker.getSignInStatus(started.sessionId), { + ok: false, + status: "error", + error: "browser launch failed", + }); + assert.deepEqual(broker.getSignInStatus(started.sessionId), { + ok: false, + status: "error", + error: "browser launch failed", + }); + assert.deepEqual(broker.cancelSignIn(started.sessionId), { ok: true }); + assert.deepEqual(broker.getSignInStatus(started.sessionId), { + ok: false, + status: "error", + error: "browser launch failed", + }); +}); + +test("legacy credential cleanup retries after a transient failure", async () => { + let cleanupCalls = 0; + const broker = new InteractiveAuthBroker({ + cleanupLegacyCredentials: async () => { + cleanupCalls++; + if (cleanupCalls === 1) throw new Error("legacy cache is locked"); + }, + createCredential: () => ({ + async getToken() { + return accessToken(); + }, + }), + loadAuthRecord: async () => authenticationRecord(), + }); + + await assert.rejects(broker.getToken(), /legacy cache is locked/); + assert.equal(await broker.getToken(), "token"); + assert.equal(cleanupCalls, 2); +}); + +test("token acquisition preserves operational errors and retries the credential", async () => { + const outage = new Error("Azure Identity network request timed out"); + let createCredentialCalls = 0; + let tokenCalls = 0; + const broker = new InteractiveAuthBroker({ + createCredential: () => { + createCredentialCalls++; + return { + async getToken() { + tokenCalls++; + if (tokenCalls === 1) throw outage; + return accessToken(); + }, + }; + }, + loadAuthRecord: async () => authenticationRecord(), + }); + + await assert.rejects(broker.getToken(), (error) => error === outage); + assert.equal(await broker.getToken(), "token"); + assert.equal(createCredentialCalls, 1); + assert.equal(tokenCalls, 2); +}); + +test("incomplete tokens remain operational errors instead of prompting sign-in", async () => { + const broker = new InteractiveAuthBroker({ + createCredential: () => ({ + async getToken() { + return { token: "incomplete" }; + }, + }), + loadAuthRecord: async () => authenticationRecord(), + }); + + await assert.rejects( + broker.getToken(), + (error) => !(error instanceof ConnectorAuthenticationRequiredError) + && error.message === "Azure identity returned an incomplete ARM access token.", + ); +}); diff --git a/extensions/connector-namespaces/extensions/connector-namespaces/extension.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/extension.mjs index fe6f502e..94025ee7 100644 --- a/extensions/connector-namespaces/extensions/connector-namespaces/extension.mjs +++ b/extensions/connector-namespaces/extensions/connector-namespaces/extension.mjs @@ -44,7 +44,7 @@ const session = await joinSession({ createCanvas({ id: "connector-namespaces", displayName: "MCP Connectors", - description: "Browse, connect, and open MCP connectors in the Azure Connector Namespace Sandbox.", + description: "Discover, connect, and manage hosted MCP servers from Azure Connector Namespace.", inputSchema: { type: "object", properties: { diff --git a/extensions/connector-namespaces/extensions/connector-namespaces/install.reauth.test.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/install.reauth.test.mjs index 50da0470..b1f2cfa0 100644 --- a/extensions/connector-namespaces/extensions/connector-namespaces/install.reauth.test.mjs +++ b/extensions/connector-namespaces/extensions/connector-namespaces/install.reauth.test.mjs @@ -12,26 +12,18 @@ import { test, after } from "node:test"; import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; -import { delimiter, join } from "node:path"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { tmpdir } from "node:os"; // Isolate COPILOT_HOME before importing install.mjs because its paths are bound at -// module-eval time. Put a fake Azure CLI on PATH so getToken() stays offline, and -// seed a profile config so the local entry reads as inCli. +// module-eval time. Seed the interactive broker with an in-memory token so ARM +// calls stay offline, and seed a profile config so the local entry reads as inCli. const TMP = mkdtempSync(join(tmpdir(), "cn-reauth-")); process.env.COPILOT_HOME = TMP; process.env.USERPROFILE = TMP; // homedir() on Windows process.env.HOME = TMP; // homedir() on posix -const binDir = join(TMP, "bin"); -mkdirSync(binDir, { recursive: true }); -const tokenJson = JSON.stringify({ accessToken: "fake-token", expires_on: Math.floor(Date.now() / 1000) + 3600 }); -writeFileSync(join(binDir, "az"), `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(tokenJson)});\n`); -chmodSync(join(binDir, "az"), 0o755); -writeFileSync(join(binDir, "az.cmd"), `@echo ${tokenJson}\r\n`); -process.env.PATH = `${binDir}${delimiter}${process.env.PATH || ""}`; - const legacyAuthCache = join(TMP, "extensions", "connector-namespaces", "artifacts", "auth-cache.json"); mkdirSync(join(TMP, "extensions", "connector-namespaces", "artifacts"), { recursive: true }); writeFileSync(legacyAuthCache, JSON.stringify({ accessToken: "legacy", refreshToken: "legacy" })); @@ -41,6 +33,14 @@ writeFileSync( JSON.stringify({ mcpServers: { "docusign-bbb": { type: "http", url: "https://example/mcp" } } }), ); +const { interactiveAuth } = await import("./auth.mjs"); +interactiveAuth.credential = { + async getToken() { + return { token: "fake-token", expiresOnTimestamp: Date.now() + 60 * 60 * 1000 }; + }, +}; +interactiveAuth.accessToken = { token: "fake-token", expiresOnTimestamp: Date.now() + 60 * 60 * 1000 }; + // Dynamic import AFTER the env is set. A static top-level import would be hoisted // and evaluate install.mjs (binding the paths to the real home) before the env // assignments run. diff --git a/extensions/connector-namespaces/extensions/connector-namespaces/package.json b/extensions/connector-namespaces/extensions/connector-namespaces/package.json index ac0c28b0..24e6f05b 100644 --- a/extensions/connector-namespaces/extensions/connector-namespaces/package.json +++ b/extensions/connector-namespaces/extensions/connector-namespaces/package.json @@ -1,9 +1,9 @@ { "name": "connector-namespaces", - "version": "1.1.0", + "version": "1.2.0", "type": "module", "main": "extension.mjs", - "description": "Browse, connect, and open MCP connectors from your Azure Connector Namespace in Sandbox.", + "description": "Interactive GitHub Copilot canvas for discovering, connecting, and managing hosted MCP servers from Azure Connector Namespace.", "keywords": [ "azure", "connector-namespace", @@ -14,6 +14,8 @@ ], "license": "MIT", "dependencies": { + "@azure/identity": "4.13.1", + "@azure/identity-cache-persistence": "1.3.0", "@github/copilot-sdk": "1.0.6" } } diff --git a/extensions/connector-namespaces/extensions/connector-namespaces/preview/README.md b/extensions/connector-namespaces/extensions/connector-namespaces/preview/README.md index 14b839b1..4413d5fb 100644 --- a/extensions/connector-namespaces/extensions/connector-namespaces/preview/README.md +++ b/extensions/connector-namespaces/extensions/connector-namespaces/preview/README.md @@ -4,7 +4,7 @@ A standalone way to **see** every canvas state without launching the Copilot app. It imports the real, pure renderer functions from `../renderer.mjs` and serves each state on a fixed loopback port, with every `/api/*` endpoint stubbed so you can force the states that keep regressing (the connecting spinner and the -"Restart your Copilot session…" banner). +"Restart the GitHub Copilot app…" banner). This exists because those two bugs have each shipped multiple times: diff --git a/extensions/connector-namespaces/extensions/connector-namespaces/renderer.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/renderer.mjs index e4ddf609..a6cdf413 100644 --- a/extensions/connector-namespaces/extensions/connector-namespaces/renderer.mjs +++ b/extensions/connector-namespaces/extensions/connector-namespaces/renderer.mjs @@ -78,6 +78,7 @@ export function baseStyles() { } } * { box-sizing: border-box; } +[hidden] { display: none !important; } body { font-family: "Segoe UI Variable", "Segoe UI", -apple-system, system-ui, sans-serif; margin: 0; @@ -303,7 +304,7 @@ button:focus-visible, a:focus-visible, [tabindex]:focus-visible { outline: 2px s // Setup / Namespace Picker // --------------------------------------------------------------------------- -export function renderSetupHtml(subscriptions, notice = "", capabilityToken = "") { +export function renderSetupHtml(subscriptions = [], notice = "", capabilityToken = "") { const subOptions = subscriptions.map((s) => `` ).join(""); @@ -336,6 +337,19 @@ export function renderSetupHtml(subscriptions, notice = "", capabilityToken = "" } .create-link:hover { background: var(--accent); color: #fff; } .create-link .plus { font-size: 1.05rem; line-height: 1; font-weight: 700; } +.create-link:disabled { opacity: .55; cursor: not-allowed; background: transparent; color: var(--fg-muted); border-color: var(--border-strong); } +.signin-panel { margin: .25rem 0 1rem; max-width: 540px; } +.signin-message { color: var(--fg-muted); font-size: .82rem; line-height: 1.5; margin: 0 0 .7rem; } +.signin-actions { display: flex; align-items: center; gap: .35rem; } +.signin-primary { min-width: 0; padding: .35rem .75rem; font-size: .82rem; } +.signin-cancel { + appearance: none; border: 0; border-radius: 4px; background: transparent; + color: var(--fg-muted); padding: .3rem .45rem; font: inherit; font-size: .78rem; + cursor: pointer; +} +.signin-cancel:hover { color: var(--accent); background: var(--bg-hover); } +.signin-actions button:disabled { opacity: .55; cursor: wait; } +.signin-panel[data-state="error"] .signin-message { color: var(--danger); } .setup-notice { margin: 0 0 1rem; padding: .55rem .7rem; border-radius: 6px; background: var(--bg-pill); border: 1px solid var(--accent); @@ -347,19 +361,28 @@ export function renderSetupHtml(subscriptions, notice = "", capabilityToken = ""