From 2f258c7226212373e1c5cabd655c2b7bd61bb3f2 Mon Sep 17 00:00:00 2001 From: "Alex Yang (DevDiv)" Date: Mon, 20 Jul 2026 13:04:18 -0700 Subject: [PATCH 1/6] 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> --- .github/plugin/marketplace.json | 2 +- .../.github/plugin/plugin.json | 2 +- extensions/connector-namespaces/README.md | 31 +- extensions/connector-namespaces/armClient.mjs | 123 +------- extensions/connector-namespaces/auth.mjs | 268 ++++++++++++++++++ extensions/connector-namespaces/auth.test.mjs | 198 +++++++++++++ .../install.reauth.test.mjs | 24 +- extensions/connector-namespaces/package.json | 4 +- .../connector-namespaces/preview/README.md | 2 +- extensions/connector-namespaces/renderer.mjs | 235 +++++++++++++-- .../connector-namespaces/renderer.test.mjs | 35 ++- .../review-fixes.test.mjs | 60 +--- extensions/connector-namespaces/server.mjs | 88 ++++-- .../connector-namespaces/server.test.mjs | 72 ++++- .../connector-namespaces/test/README.md | 23 +- .../connector-namespaces/test/smoke.mjs | 36 ++- 16 files changed, 945 insertions(+), 258 deletions(-) create mode 100644 extensions/connector-namespaces/auth.mjs create mode 100644 extensions/connector-namespaces/auth.test.mjs diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 9fb079f5e..e8e4056f5 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -245,7 +245,7 @@ "name": "connector-namespaces", "source": "extensions/connector-namespaces", "description": "Browse, connect, and open MCP connectors from an Azure Connector Namespace.", - "version": "1.1.2" + "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 a75cb84cc..f789e6ab6 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", + "version": "1.2.0", "author": { "name": "Alex Yang", "url": "https://github.com/alexyaang" diff --git a/extensions/connector-namespaces/README.md b/extensions/connector-namespaces/README.md index f73462479..608445464 100644 --- a/extensions/connector-namespaces/README.md +++ b/extensions/connector-namespaces/README.md @@ -6,14 +6,16 @@ session. Search by name or category, sign in to a connector, then restart the session to make its tools available to the agent. > 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. +> using an interactive Microsoft Entra browser sign-in. It does not register its +> own Entra application. Azure Identity persists the account session in the +> operating system's encrypted credential store. ## 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. +- A browser for Microsoft Entra sign-in. The extension prompts when Azure + authentication is required and restores the encrypted Azure Identity session + after extension or Copilot CLI restarts. - **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 @@ -41,14 +43,15 @@ The destination **scope** is chosen at install time: ## Usage 1. Open the **MCP Connectors** canvas from Copilot CLI. -2. The canvas loads subscriptions from your signed-in Azure CLI account. Pick an +2. If prompted, choose **Sign in** and complete Microsoft Entra authentication + in the browser. The canvas reloads your subscriptions automatically. 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. +5. Restart the GitHub Copilot app so the agent can load the connected tools. The extension registers the native `connector_namespaces_open_playground` tool, so GitHub Copilot can open a named connector from **My MCPs** without installing @@ -59,9 +62,11 @@ an additional Agent Skill. - `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). + the canvas UI and the JSON/authentication endpoints the iframe calls. +- `auth.mjs` — `InteractiveBrowserCredential` broker for sign-in lifecycle, + cancellation, encrypted token-cache persistence, and ARM token refresh. +- `armClient.mjs` — thin ARM client (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). @@ -71,9 +76,11 @@ an additional Agent Skill. ## Privacy & security -- 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. +- ARM tokens come from `@azure/identity`'s `InteractiveBrowserCredential` and + are never logged. Azure Identity stores its refreshable account cache through + the operating system's encrypted credential store. A non-secret account + locator is saved with user-only permissions so a new process can select that + cache entry; the extension never writes raw tokens to its artifact files. - 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. diff --git a/extensions/connector-namespaces/armClient.mjs b/extensions/connector-namespaces/armClient.mjs index dc0f9fc46..c4ef7f4a5 100644 --- a/extensions/connector-namespaces/armClient.mjs +++ b/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/auth.mjs b/extensions/connector-namespaces/auth.mjs new file mode 100644 index 000000000..e5475016c --- /dev/null +++ b/extensions/connector-namespaces/auth.mjs @@ -0,0 +1,268 @@ +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); + +async function loadAuthenticationRecord() { + try { + const serialized = await fs.readFile(AUTH_RECORD_FILE, "utf-8"); + return deserializeAuthenticationRecord(serialized); + } catch (error) { + if (error?.code === "ENOENT") return undefined; + throw error; + } +} + +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) { + this.cleanupInFlight = Promise.resolve().then(() => this.cleanupLegacyCredentials()); + } + 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" }; + + this.sessions.delete(sessionId); + 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) 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; + + let credential = this.credential; + if (!credential) { + try { + const authenticationRecord = await this.loadAuthRecord(); + credential = this.createInteractiveCredential(authenticationRecord); + this.credential = credential; + } catch (error) { + throw new ConnectorAuthenticationRequiredError( + "Azure sign-in is unavailable. Check the secure token cache installation and try again.", + { cause: error }, + ); + } + } + const request = credential.getToken(this.scope) + .then((accessToken) => { + 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 (this.credential === credential) { + this.credential = null; + this.accessToken = null; + } + throw new ConnectorAuthenticationRequiredError( + "Sign in to Azure to continue.", + { cause: error }, + ); + }) + .finally(() => { + if (this.tokenInFlight === request) this.tokenInFlight = null; + }); + this.tokenInFlight = request; + return request; + } +} + +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/auth.test.mjs b/extensions/connector-namespaces/auth.test.mjs new file mode 100644 index 000000000..38191baec --- /dev/null +++ b/extensions/connector-namespaces/auth.test.mjs @@ -0,0 +1,198 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + ConnectorAuthenticationRequiredError, + InteractiveAuthBroker, + 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("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.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("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() { + throw new Error("getToken should not run after cancellation"); + }, + }; + 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" }); + 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", + }); +}); diff --git a/extensions/connector-namespaces/install.reauth.test.mjs b/extensions/connector-namespaces/install.reauth.test.mjs index 50da04707..b1f2cfa00 100644 --- a/extensions/connector-namespaces/install.reauth.test.mjs +++ b/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/package.json b/extensions/connector-namespaces/package.json index ac0c28b04..a2a81c31b 100644 --- a/extensions/connector-namespaces/package.json +++ b/extensions/connector-namespaces/package.json @@ -1,6 +1,6 @@ { "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.", @@ -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/preview/README.md b/extensions/connector-namespaces/preview/README.md index 14b839b1d..4413d5fb3 100644 --- a/extensions/connector-namespaces/preview/README.md +++ b/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/renderer.mjs b/extensions/connector-namespaces/renderer.mjs index e4ddf609d..ed5094bef 100644 --- a/extensions/connector-namespaces/renderer.mjs +++ b/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 = ""
Choose which connector namespace to browse. This choice is saved for future sessions.
${notice ? `
${esc(notice)}
` : ""} -
- -
-
- - + - -
-
Select a subscription to see available connector namespaces.
+
+
+ +
+
+ + +
+ +
+
${subscriptions.length ? "Select a subscription to see available connector namespaces." : "Loading subscriptions\u2026"}
+
`; } @@ -592,11 +778,6 @@ export function renderCatalogHtml(instanceId, catalog, { filter, category, sourc .restart-banner .rb-dismiss { flex:none; appearance:none; border:0; background:transparent; color:var(--fg-muted); font:inherit; font-size:.78rem; cursor:pointer; padding:.1rem .35rem; border-radius:4px; } .restart-banner .rb-dismiss:hover { color:var(--accent); background:var(--bg-hover); } .is-hidden { display:none !important; } -/* The [hidden] attribute must always win. A class rule like .restart-banner{display:flex} - has the same (0,1,0) specificity as the UA [hidden]{display:none} rule and, being an - author rule, overrides it -- so setting el.hidden=true does nothing and dismiss silently - breaks. This reset restores the attribute's authority for every element. */ -[hidden] { display:none !important; } /* ---- split "remove" control + its popover menu + delete-confirm dialog ---- */ /* main + caret read as one pill; the shared 1px border between them is the @@ -688,7 +869,7 @@ export function renderCatalogHtml(instanceId, catalog, { filter, category, sourc
${sectionsHtml} @@ -866,9 +1047,9 @@ if (input.value) applyFilters(); // for now because it writes a plaintext API key into a git-tracked .mcp.json. const installScope = "profile"; -// --- Restart-required banner (tools load at session start) --- +// --- Restart-required banner (tools load at app start) --- // Visibility is driven by the server's in-process pendingRestart flag via -// /api/state, not local storage — a real session restart spawns a fresh +// /api/state, not local storage — a full app restart spawns a fresh // extension process and clears it, so the banner can't go stale. const restartBanner = document.getElementById("restart-banner"); // Once the user dismisses the banner, a late/racing hydrateState (its @@ -1068,7 +1249,7 @@ function openSignInModal(displayName, consentUrl) { cancellable = false; icon.innerHTML = '
\u2713
'; title.textContent = "Connected"; - sub.textContent = displayName + " is configured. Restart your Copilot session to load its tools."; + sub.textContent = displayName + " is configured. Restart the GitHub Copilot app to load its tools."; meta.textContent = ""; }, close() { doClose(); }, @@ -1116,7 +1297,7 @@ async function onConnect(btn) { pendingConn = null; } - await showConnectionSuccess(modal, item, 'Connected "' + displayName + '". Restart your session to use its tools.'); + await showConnectionSuccess(modal, item, 'Connected "' + displayName + '". Restart the GitHub Copilot app to use its tools.'); } catch (err) { const recovery = await recoverConnectorFailure( err, @@ -1128,7 +1309,7 @@ async function onConnect(btn) { true ); if (recovery.complete) { - await showConnectionSuccess(modal, item, 'Connected "' + displayName + '". Restart your session to use its tools.'); + await showConnectionSuccess(modal, item, 'Connected "' + displayName + '". Restart the GitHub Copilot app to use its tools.'); return; } if (modal) modal.close(); @@ -1218,7 +1399,7 @@ async function onReauth(btn) { await showConnectionSuccess( modal, item, - (isConnect ? 'Connected "' : 'Re-authenticated "') + displayName + '". Restart your session to use its tools.' + (isConnect ? 'Connected "' : 'Re-authenticated "') + displayName + '". Restart the GitHub Copilot app to use its tools.' ); } catch (err) { const recovery = await recoverConnectorFailure( @@ -1234,7 +1415,7 @@ async function onReauth(btn) { await showConnectionSuccess( modal, item, - (isConnect ? 'Connected "' : 'Re-authenticated "') + displayName + '". Restart your session to use its tools.' + (isConnect ? 'Connected "' : 'Re-authenticated "') + displayName + '". Restart the GitHub Copilot app to use its tools.' ); return; } diff --git a/extensions/connector-namespaces/renderer.test.mjs b/extensions/connector-namespaces/renderer.test.mjs index d0c464e45..3a3899cb9 100644 --- a/extensions/connector-namespaces/renderer.test.mjs +++ b/extensions/connector-namespaces/renderer.test.mjs @@ -7,7 +7,7 @@ // loaders without a visible fallback. Reduced motion now stops the // animation while forcing each loader into a visible static busy state; // nearby text continues to communicate progress. -// 2. The "Restart your Copilot session" banner ignoring Dismiss. The real +// 2. The "Restart the GitHub Copilot app" banner ignoring Dismiss. The real // root cause was CSS specificity: `.restart-banner{display:flex}` is an // author rule with the same (0,1,0) specificity as the UA // `[hidden]{display:none}` rule, so it overrode the hidden attribute and @@ -56,6 +56,33 @@ test("setup subscription label names its select", () => { assert.match(html, /