Improve connector reload authentication UX (#2370)

Keep Azure tokens in memory and preserve linked namespace context across extension reloads.

Co-authored-by: Alex Yang (DevDiv) <yangalex@microsoft.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Alex Yang [MSFT]
2026-07-23 10:51:00 -07:00
committed by GitHub
parent 26a363779a
commit c0869f6231
10 changed files with 110 additions and 171 deletions
+9 -13
View File
@@ -16,8 +16,8 @@ and connected-server management into the Copilot side panel.
- **My MCPs** - see which servers are connected and ready to add to Copilot. - **My MCPs** - see which servers are connected and ready to add to Copilot.
- **Namespace playground** - open any connected server in the Connector - **Namespace playground** - open any connected server in the Connector
Namespace playground with **Sandbox**. Namespace playground with **Sandbox**.
- **Persistent setup** - retain the selected namespace and restore Azure sign-in - **Persistent namespace selection** - retain the selected namespace while Azure
securely across app restarts. tokens remain in memory and sign-in is requested again after a restart.
## Install ## Install
@@ -36,10 +36,6 @@ and select **Install in GitHub Copilot app**.
- Permission to view the namespace and create its connections and hosted MCP - Permission to view the namespace and create its connections and hosted MCP
server configurations. server configurations.
- A browser for Microsoft Entra sign-in and connector consent. - 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.
Connector Namespace is currently an Azure preview service and availability can Connector Namespace is currently an Azure preview service and availability can
vary by region. vary by region.
@@ -64,13 +60,13 @@ playground. Use **Change namespace** to switch subscriptions or namespaces.
Azure sign-in and connector sign-in are separate: Azure sign-in and connector sign-in are separate:
- **Azure sign-in** lets the canvas discover and manage Connector Namespace - **Azure sign-in** lets the canvas discover and manage Connector Namespace
resources. Access and refresh tokens are stored in the operating system's resources. Access and refresh tokens remain in the extension process and are
encrypted credential store. To select that encrypted cache entry after an app never written to extension files. Reloading the extension or restarting the
restart, the extension separately saves a non-secret authentication record app requires Azure sign-in again. The selected namespace coordinates are
containing the authority, client ID, account ID, tenant ID, and username under retained in
`~/.copilot/extensions/connector-namespaces/artifacts/azure-auth-record.json`, `~/.copilot/extensions/connector-namespaces/artifacts/gateway-config.json` so
with user-only permissions where supported. Raw tokens are never written to the canvas can explain that the namespace is still linked and return directly
extension files. to its connectors after sign-in.
- **Connector sign-in** grants an individual MCP server access to its backing - **Connector sign-in** grants an individual MCP server access to its backing
service. The resulting connection is managed by Connector Namespace. service. The resulting connection is managed by Connector Namespace.
+16 -72
View File
@@ -2,16 +2,9 @@ import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { import { InteractiveBrowserCredential } from "@azure/identity";
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 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 TOKEN_EXPIRY_SKEW_MS = 5 * 60 * 1000;
const SIGN_IN_SESSION_TTL_MS = 10 * 60 * 1000; const SIGN_IN_SESSION_TTL_MS = 10 * 60 * 1000;
@@ -24,50 +17,20 @@ const AUTH_STORAGE_DIR = join(
const AUTH_RECORD_FILE = join(AUTH_STORAGE_DIR, "azure-auth-record.json"); const AUTH_RECORD_FILE = join(AUTH_STORAGE_DIR, "azure-auth-record.json");
const LEGACY_AUTH_CACHE = join(AUTH_STORAGE_DIR, "auth-cache.json"); const LEGACY_AUTH_CACHE = join(AUTH_STORAGE_DIR, "auth-cache.json");
let legacyAuthCacheRemoved = false; let legacyAuthArtifactsRemoved = false;
useIdentityPlugin(cachePersistencePlugin); async function removeLegacyAuthArtifacts() {
if (legacyAuthArtifactsRemoved) return;
export async function loadAuthenticationRecord({ for (const path of [AUTH_RECORD_FILE, LEGACY_AUTH_CACHE]) {
readFile = fs.readFile, try {
deserialize = deserializeAuthenticationRecord, await fs.unlink(path);
authRecordFile = AUTH_RECORD_FILE, } catch (error) {
} = {}) { if (error?.code !== "ENOENT") {
let serialized; throw new Error(`Could not remove the legacy connector authentication file at ${path}: ${error.message}`);
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; legacyAuthArtifactsRemoved = true;
} }
export class ConnectorAuthenticationRequiredError extends Error { export class ConnectorAuthenticationRequiredError extends Error {
@@ -103,20 +66,14 @@ export class InteractiveAuthBroker {
createCredential = credentialFactory, createCredential = credentialFactory,
createSessionId = randomUUID, createSessionId = randomUUID,
cleanupLegacyCredentials = async () => {}, cleanupLegacyCredentials = async () => {},
loadAuthRecord = async () => undefined,
saveAuthRecord = async () => {},
now = Date.now, now = Date.now,
scope = ARM_SCOPE, scope = ARM_SCOPE,
tokenCacheName = TOKEN_CACHE_NAME,
} = {}) { } = {}) {
this.createCredential = createCredential; this.createCredential = createCredential;
this.createSessionId = createSessionId; this.createSessionId = createSessionId;
this.cleanupLegacyCredentials = cleanupLegacyCredentials; this.cleanupLegacyCredentials = cleanupLegacyCredentials;
this.loadAuthRecord = loadAuthRecord;
this.saveAuthRecord = saveAuthRecord;
this.now = now; this.now = now;
this.scope = scope; this.scope = scope;
this.tokenCacheName = tokenCacheName;
this.credential = null; this.credential = null;
this.accessToken = null; this.accessToken = null;
this.cleanupInFlight = null; this.cleanupInFlight = null;
@@ -124,15 +81,10 @@ export class InteractiveAuthBroker {
this.sessions = new Map(); this.sessions = new Map();
} }
createInteractiveCredential(authenticationRecord) { createInteractiveCredential() {
return this.createCredential({ return this.createCredential({
redirectUri: "http://localhost", redirectUri: "http://localhost",
disableAutomaticAuthentication: true, disableAutomaticAuthentication: true,
tokenCachePersistenceOptions: {
enabled: true,
name: this.tokenCacheName,
},
...(authenticationRecord ? { authenticationRecord } : {}),
}); });
} }
@@ -188,7 +140,6 @@ export class InteractiveAuthBroker {
if (!authenticationRecord) { if (!authenticationRecord) {
throw new Error("Azure identity did not return an authentication record."); 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 }); const accessToken = await credential.getToken(this.scope, { abortSignal: abortController.signal });
if (!accessToken?.token || !Number.isFinite(accessToken.expiresOnTimestamp)) { if (!accessToken?.token || !Number.isFinite(accessToken.expiresOnTimestamp)) {
throw new Error("Azure identity returned an incomplete ARM access token."); throw new Error("Azure identity returned an incomplete ARM access token.");
@@ -235,13 +186,8 @@ export class InteractiveAuthBroker {
let credential = this.credential; let credential = this.credential;
if (!credential) { if (!credential) {
try { try {
const authenticationRecord = await this.loadAuthRecord(); credential = this.createInteractiveCredential();
if (hasUsableToken(this.accessToken, this.now())) return this.accessToken.token; this.credential = credential;
credential = this.credential;
if (!credential) {
credential = this.createInteractiveCredential(authenticationRecord);
this.credential = credential;
}
} catch (error) { } catch (error) {
if (isAuthenticationRequiredError(error)) { if (isAuthenticationRequiredError(error)) {
throw new ConnectorAuthenticationRequiredError( throw new ConnectorAuthenticationRequiredError(
@@ -281,9 +227,7 @@ export class InteractiveAuthBroker {
} }
export const interactiveAuth = new InteractiveAuthBroker({ export const interactiveAuth = new InteractiveAuthBroker({
cleanupLegacyCredentials: removeLegacyAuthCache, cleanupLegacyCredentials: removeLegacyAuthArtifacts,
loadAuthRecord: loadAuthenticationRecord,
saveAuthRecord: saveAuthenticationRecord,
}); });
export const startSignIn = () => interactiveAuth.startSignIn(); export const startSignIn = () => interactiveAuth.startSignIn();
+22 -59
View File
@@ -4,8 +4,6 @@ import assert from "node:assert/strict";
import { import {
ConnectorAuthenticationRequiredError, ConnectorAuthenticationRequiredError,
InteractiveAuthBroker, InteractiveAuthBroker,
loadAuthenticationRecord,
TOKEN_CACHE_NAME,
} from "./auth.mjs"; } from "./auth.mjs";
function accessToken(token = "token", expiresOnTimestamp = 2_000_000_000_000) { function accessToken(token = "token", expiresOnTimestamp = 2_000_000_000_000) {
@@ -35,7 +33,6 @@ test("ARM token requests require an explicit browser sign-in", async () => {
}, },
}; };
}, },
loadAuthRecord: async () => undefined,
}); });
await assert.rejects( await assert.rejects(
@@ -43,32 +40,13 @@ test("ARM token requests require an explicit browser sign-in", async () => {
(error) => error instanceof ConnectorAuthenticationRequiredError (error) => error instanceof ConnectorAuthenticationRequiredError
&& error.code === "authentication_required", && error.code === "authentication_required",
); );
assert.deepEqual(credentialOptions.tokenCachePersistenceOptions, { assert.deepEqual(credentialOptions, {
enabled: true, redirectUri: "http://localhost",
name: TOKEN_CACHE_NAME, disableAutomaticAuthentication: true,
}); });
}); });
test("a malformed authentication record falls back to browser sign-in", async () => { test("interactive sign-in reports pending then done and keeps the ARM token in memory", 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 credentialOptions;
let authenticateOptions; let authenticateOptions;
const credential = { const credential = {
@@ -89,7 +67,6 @@ test("interactive sign-in reports pending then done and caches the ARM token", a
return credential; return credential;
}, },
createSessionId: () => "signin-session", createSessionId: () => "signin-session",
saveAuthRecord: async (record) => assert.deepEqual(record, authenticationRecord()),
now: () => 1_000, now: () => 1_000,
}); });
@@ -110,10 +87,6 @@ test("interactive sign-in reports pending then done and caches the ARM token", a
assert.deepEqual(credentialOptions, { assert.deepEqual(credentialOptions, {
redirectUri: "http://localhost", redirectUri: "http://localhost",
disableAutomaticAuthentication: true, disableAutomaticAuthentication: true,
tokenCachePersistenceOptions: {
enabled: true,
name: TOKEN_CACHE_NAME,
},
}); });
assert.equal(authenticateOptions.abortSignal.aborted, false); 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" });
@@ -124,22 +97,24 @@ test("interactive sign-in reports pending then done and caches the ARM token", a
assert.equal(await broker.getToken(), "token"); assert.equal(await broker.getToken(), "token");
}); });
test("a new broker restores the persisted credential without reopening the browser", async () => { test("a new broker requires browser sign-in after the extension reloads", async () => {
const cache = { record: null, token: null };
let authenticateCalls = 0; let authenticateCalls = 0;
let createCredentialCalls = 0;
const createCredential = (options) => { const createCredential = (options) => {
assert.deepEqual(options.tokenCachePersistenceOptions, { createCredentialCalls++;
enabled: true, assert.deepEqual(options, {
name: TOKEN_CACHE_NAME, redirectUri: "http://localhost",
disableAutomaticAuthentication: true,
}); });
let signedIn = false;
return { return {
async authenticate() { async authenticate() {
authenticateCalls++; authenticateCalls++;
cache.token = accessToken("persisted-token"); signedIn = true;
return authenticationRecord(); return authenticationRecord();
}, },
async getToken() { async getToken() {
if (cache.token && options.authenticationRecord) return cache.token; if (signedIn) return accessToken("memory-token");
const error = new Error("No cached account found."); const error = new Error("No cached account found.");
error.name = "AuthenticationRequiredError"; error.name = "AuthenticationRequiredError";
throw error; throw error;
@@ -149,41 +124,36 @@ test("a new broker restores the persisted credential without reopening the brows
const signedInBroker = new InteractiveAuthBroker({ const signedInBroker = new InteractiveAuthBroker({
createCredential, createCredential,
createSessionId: () => "persist-session", createSessionId: () => "persist-session",
saveAuthRecord: async (record) => { cache.record = record; },
now: () => 1_000, now: () => 1_000,
}); });
const started = signedInBroker.startSignIn(); const started = signedInBroker.startSignIn();
await signedInBroker.sessions.get(started.sessionId).promise; await signedInBroker.sessions.get(started.sessionId).promise;
assert.equal(authenticateCalls, 1); assert.equal(authenticateCalls, 1);
assert.equal(await signedInBroker.getToken(), "memory-token");
const restartedBroker = new InteractiveAuthBroker({ const restartedBroker = new InteractiveAuthBroker({
createCredential, createCredential,
loadAuthRecord: async () => cache.record,
now: () => 1_000, now: () => 1_000,
}); });
assert.equal(await restartedBroker.getToken(), "persisted-token"); await assert.rejects(restartedBroker.getToken(), ConnectorAuthenticationRequiredError);
assert.equal(authenticateCalls, 1); assert.equal(authenticateCalls, 1);
assert.equal(createCredentialCalls, 2);
}); });
test("concurrent first-time token requests share credential initialization and acquisition", async () => { test("concurrent first-time token requests share credential acquisition", async () => {
let releaseAuthenticationRecord; let releaseToken;
const authenticationRecordReady = new Promise((resolve) => { const tokenReady = new Promise((resolve) => {
releaseAuthenticationRecord = resolve; releaseToken = resolve;
}); });
let loadAuthRecordCalls = 0;
let createCredentialCalls = 0; let createCredentialCalls = 0;
let tokenCalls = 0; let tokenCalls = 0;
const broker = new InteractiveAuthBroker({ const broker = new InteractiveAuthBroker({
async loadAuthRecord() {
loadAuthRecordCalls++;
await authenticationRecordReady;
return authenticationRecord();
},
createCredential: () => { createCredential: () => {
createCredentialCalls++; createCredentialCalls++;
return { return {
async getToken() { async getToken() {
tokenCalls++; tokenCalls++;
await tokenReady;
return accessToken("shared-token"); return accessToken("shared-token");
}, },
}; };
@@ -193,15 +163,12 @@ test("concurrent first-time token requests share credential initialization and a
const firstRequest = broker.getToken(); const firstRequest = broker.getToken();
const secondRequest = broker.getToken(); const secondRequest = broker.getToken();
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
const loadsBeforeRelease = loadAuthRecordCalls; releaseToken();
releaseAuthenticationRecord();
assert.deepEqual( assert.deepEqual(
await Promise.all([firstRequest, secondRequest]), await Promise.all([firstRequest, secondRequest]),
["shared-token", "shared-token"], ["shared-token", "shared-token"],
); );
assert.equal(loadsBeforeRelease, 1);
assert.equal(loadAuthRecordCalls, 1);
assert.equal(createCredentialCalls, 1); assert.equal(createCredentialCalls, 1);
assert.equal(tokenCalls, 1); assert.equal(tokenCalls, 1);
}); });
@@ -224,7 +191,6 @@ test("cancelling sign-in aborts the credential request", async () => {
const broker = new InteractiveAuthBroker({ const broker = new InteractiveAuthBroker({
createCredential: () => credential, createCredential: () => credential,
createSessionId: () => "cancel-session", createSessionId: () => "cancel-session",
loadAuthRecord: async () => undefined,
now: () => 1_000, now: () => 1_000,
}); });
@@ -289,7 +255,6 @@ test("legacy credential cleanup retries after a transient failure", async () =>
return accessToken(); return accessToken();
}, },
}), }),
loadAuthRecord: async () => authenticationRecord(),
}); });
await assert.rejects(broker.getToken(), /legacy cache is locked/); await assert.rejects(broker.getToken(), /legacy cache is locked/);
@@ -312,7 +277,6 @@ test("token acquisition preserves operational errors and retries the credential"
}, },
}; };
}, },
loadAuthRecord: async () => authenticationRecord(),
}); });
await assert.rejects(broker.getToken(), (error) => error === outage); await assert.rejects(broker.getToken(), (error) => error === outage);
@@ -328,7 +292,6 @@ test("incomplete tokens remain operational errors instead of prompting sign-in",
return { token: "incomplete" }; return { token: "incomplete" };
}, },
}), }),
loadAuthRecord: async () => authenticationRecord(),
}); });
await assert.rejects( await assert.rejects(
@@ -15,7 +15,6 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@azure/identity": "4.13.1", "@azure/identity": "4.13.1",
"@azure/identity-cache-persistence": "1.3.0",
"@github/copilot-sdk": "1.0.6" "@github/copilot-sdk": "1.0.6"
} }
} }
+27 -9
View File
@@ -304,14 +304,23 @@ button:focus-visible, a:focus-visible, [tabindex]:focus-visible { outline: 2px s
// Setup / Namespace Picker // Setup / Namespace Picker
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function renderSetupHtml(subscriptions = [], notice = "", capabilityToken = "") { export function renderSetupHtml(subscriptions = [], notice = "", capabilityToken = "", { linkedNamespace = "" } = {}) {
const hasLinkedNamespace = typeof linkedNamespace === "string" && linkedNamespace.length > 0;
const pageTitle = hasLinkedNamespace ? "Sign in to MCP Connectors" : "Select Connector Namespace";
const heading = hasLinkedNamespace ? "Sign in to see your connectors" : "Select a Connector Namespace";
const subheading = hasLinkedNamespace
? `Connector namespace <code>${esc(linkedNamespace)}</code> is already linked.`
: "Choose which connector namespace to browse. This choice is saved for future sessions.";
const defaultSigninMessage = hasLinkedNamespace
? "Sign in to Azure to view and manage its connectors."
: "Sign in to Azure to load your subscriptions and connector namespaces.";
const subOptions = subscriptions.map((s) => const subOptions = subscriptions.map((s) =>
`<option value="${s.id}">${esc(s.name)} (${s.id.slice(0, 8)}\u2026)</option>` `<option value="${s.id}">${esc(s.name)} (${s.id.slice(0, 8)}\u2026)</option>`
).join(""); ).join("");
return `<!doctype html><html lang="en"><head><meta charset="utf-8"> return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Select Connector Namespace</title>${baseStyles()} <title>${pageTitle}</title>${baseStyles()}
<style> <style>
.skeleton { animation: pulse 1.2s ease-in-out infinite; } .skeleton { animation: pulse 1.2s ease-in-out infinite; }
@keyframes pulse { 0%,100% { opacity: .4; } 50% { opacity: .8; } } @keyframes pulse { 0%,100% { opacity: .4; } 50% { opacity: .8; } }
@@ -357,18 +366,18 @@ export function renderSetupHtml(subscriptions = [], notice = "", capabilityToken
} }
</style></head><body> </style></head><body>
<div class="header brand-head"> <div class="header brand-head">
<h1>${brandMark(30, "setup")}<span>Select a Connector Namespace</span></h1> <h1>${brandMark(30, "setup")}<span>${heading}</span></h1>
<div class="sub">Choose which connector namespace to browse. This choice is saved for future sessions.</div> <div class="sub">${subheading}</div>
</div> </div>
${notice ? `<div class="setup-notice">${esc(notice)}</div>` : ""} ${notice ? `<div class="setup-notice">${esc(notice)}</div>` : ""}
<div id="signin-panel" class="signin-panel" data-state="idle" aria-busy="false" hidden> <div id="signin-panel" class="signin-panel" data-state="idle" aria-busy="false" hidden>
<p id="signin-message" class="signin-message" aria-live="polite">Sign in to Azure to load your subscriptions and connector namespaces.</p> <p id="signin-message" class="signin-message" aria-live="polite">${defaultSigninMessage}</p>
<div class="signin-actions"> <div class="signin-actions">
<button id="signin-btn" class="item-add primary signin-primary" type="button">Sign in to Azure</button> <button id="signin-btn" class="item-add primary signin-primary" type="button">Sign in to Azure</button>
<button id="cancel-signin-btn" class="signin-cancel" type="button" hidden>Cancel</button> <button id="cancel-signin-btn" class="signin-cancel" type="button" hidden>Cancel</button>
</div> </div>
</div> </div>
<div id="setup-content"> <div id="setup-content"${hasLinkedNamespace ? " hidden" : ""}>
<div class="create-row"> <div class="create-row">
<button id="create-ns-btn" class="create-link" type="button"${subscriptions.length ? "" : " disabled"}><span class="plus">+</span><span>New connector namespace</span></button> <button id="create-ns-btn" class="create-link" type="button"${subscriptions.length ? "" : " disabled"}><span class="plus">+</span><span>New connector namespace</span></button>
</div> </div>
@@ -386,6 +395,8 @@ ${notice ? `<div class="setup-notice">${esc(notice)}</div>` : ""}
</div> </div>
<script> <script>
const connectorNamespaceToken = ${JSON.stringify(capabilityToken)}; const connectorNamespaceToken = ${JSON.stringify(capabilityToken)};
const hasLinkedNamespace = ${hasLinkedNamespace};
const defaultSigninMessage = ${JSON.stringify(defaultSigninMessage)};
const rawFetch = window.fetch.bind(window); const rawFetch = window.fetch.bind(window);
window.fetch = (input, init = {}) => { window.fetch = (input, init = {}) => {
const url = typeof input === "string" ? input : input && input.url; const url = typeof input === "string" ? input : input && input.url;
@@ -423,7 +434,7 @@ function setSigninRequired(message, state = "idle") {
signinPanel.dataset.state = state; signinPanel.dataset.state = state;
signinPanel.setAttribute("aria-busy", "false"); signinPanel.setAttribute("aria-busy", "false");
setupContent.hidden = true; setupContent.hidden = true;
signinMessage.textContent = message || "Sign in to Azure to load your subscriptions and connector namespaces."; signinMessage.textContent = message || defaultSigninMessage;
signinButton.disabled = false; signinButton.disabled = false;
signinButton.hidden = false; signinButton.hidden = false;
cancelSigninButton.hidden = true; cancelSigninButton.hidden = true;
@@ -530,8 +541,14 @@ async function pollSignin() {
stopSigninPolling(); stopSigninPolling();
signinPanel.dataset.state = "pending"; signinPanel.dataset.state = "pending";
signinPanel.setAttribute("aria-busy", "true"); signinPanel.setAttribute("aria-busy", "true");
signinMessage.textContent = "Signed in. Loading subscriptions\u2026"; signinMessage.textContent = hasLinkedNamespace
? "Signed in. Loading your connectors\u2026"
: "Signed in. Loading subscriptions\u2026";
signinPanel.hidden = false; signinPanel.hidden = false;
if (hasLinkedNamespace) {
window.location.replace("/");
return;
}
await loadSubscriptions(true); await loadSubscriptions(true);
return; return;
} }
@@ -691,7 +708,8 @@ async function selectGateway(subscriptionId, resourceGroup, gatewayName) {
else { gatewayList.innerHTML = '<div class="empty" style="color:var(--danger);">Failed to save.</div>'; } else { gatewayList.innerHTML = '<div class="empty" style="color:var(--danger);">Failed to save.</div>'; }
} }
if (${subscriptions.length > 0}) setSetupReady(); if (hasLinkedNamespace) setSigninRequired();
else if (${subscriptions.length > 0}) setSetupReady();
else loadSubscriptions(false); else loadSubscriptions(false);
</script></body></html>`; </script></body></html>`;
} }
@@ -67,6 +67,27 @@ test("setup prompts, polls, cancels, and reloads subscriptions after browser sig
assert.match(html, /\/api\/subscriptions/); assert.match(html, /\/api\/subscriptions/);
}); });
test("a linked namespace gets a focused sign-in state and returns to its catalog", () => {
const html = renderSetupHtml([], "", "token", { linkedNamespace: "saved-gateway" });
assert.match(html, /Sign in to see your connectors/);
assert.match(html, /Connector namespace <code>saved-gateway<\/code> is already linked\./);
assert.match(html, /Sign in to Azure to view and manage its connectors\./);
assert.match(html, /const hasLinkedNamespace = true/);
assert.match(html, /window\.location\.replace\("\/"\)/);
assert.match(html, /<div id="setup-content" hidden>/);
});
test("linked namespace sign-in escapes saved names and produces valid client JavaScript", () => {
const html = renderSetupHtml([], "", "token", {
linkedNamespace: `gateway</code><script>alert("xss")</script>`,
});
assert.doesNotMatch(html, /<script>alert\("xss"\)<\/script>/);
assert.match(html, /gateway&lt;\/code&gt;&lt;script&gt;alert\(&quot;xss&quot;\)&lt;\/script&gt;/);
const script = html.match(/<script>([\s\S]*)<\/script>/)?.[1];
assert.ok(script, "linked setup page must include its client script");
assert.doesNotThrow(() => new Function(script));
});
test("setup sign-in uses a compact borderless blank state", () => { test("setup sign-in uses a compact borderless blank state", () => {
const html = renderSetupHtml([], "", "token"); const html = renderSetupHtml([], "", "token");
assert.match(html, /id="signin-btn" class="item-add primary signin-primary"/); assert.match(html, /id="signin-btn" class="item-add primary signin-primary"/);
@@ -49,17 +49,16 @@ test("namespace creation polls an empty 202 result until explicit success", asyn
); );
}); });
test("Azure authentication uses an interactive browser and persistent encrypted cache", async () => { test("Azure authentication uses an interactive browser with process-memory tokens", async () => {
const [authSource, armSource] = await Promise.all([ const [authSource, armSource] = await Promise.all([
readFile(new URL("auth.mjs", here), "utf8"), readFile(new URL("auth.mjs", here), "utf8"),
readFile(new URL("armClient.mjs", here), "utf8"), readFile(new URL("armClient.mjs", here), "utf8"),
]); ]);
assert.match(authSource, /new InteractiveBrowserCredential\(options\)/); assert.match(authSource, /new InteractiveBrowserCredential\(options\)/);
assert.match(authSource, /useIdentityPlugin\(cachePersistencePlugin\)/);
assert.match(authSource, /disableAutomaticAuthentication: true/); assert.match(authSource, /disableAutomaticAuthentication: true/);
assert.match(authSource, /tokenCachePersistenceOptions/);
assert.match(authSource, /credential\.authenticate\(\s*this\.scope,\s*\{ abortSignal/); assert.match(authSource, /credential\.authenticate\(\s*this\.scope,\s*\{ abortSignal/);
assert.match(authSource, /serializeAuthenticationRecord/); assert.doesNotMatch(authSource, /identity-cache-persistence|cachePersistencePlugin|tokenCachePersistenceOptions/);
assert.doesNotMatch(authSource, /serializeAuthenticationRecord|saveAuthenticationRecord/);
assert.doesNotMatch(armSource, /get-access-token|az login|resolvePosixAzureCli/); assert.doesNotMatch(armSource, /get-access-token|az login|resolvePosixAzureCli/);
}); });
+3 -1
View File
@@ -561,7 +561,9 @@ async function handleRequest(req, res, instanceId, serverEntry) {
} catch (err) { } catch (err) {
res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Content-Type", "text/html; charset=utf-8");
if (isAuthenticationRequiredError(err)) { if (isAuthenticationRequiredError(err)) {
res.end(renderSetupHtml([], "", serverEntry.token)); res.end(renderSetupHtml([], "", serverEntry.token, {
linkedNamespace: config.gatewayName,
}));
} else { } else {
res.end(renderSetupHtml( res.end(renderSetupHtml(
[], [],
@@ -164,7 +164,9 @@ test("saved namespace prompts for sign-in after the extension credential is rese
const response = await fetch(entry.url); const response = await fetch(entry.url);
const html = await response.text(); const html = await response.text();
assert.match(html, /id="signin-btn"/); assert.match(html, /id="signin-btn"/);
assert.match(html, /Sign in to Azure/); assert.match(html, /Sign in to see your connectors/);
assert.match(html, /Connector namespace <code>yeah-github-cli<\/code> is already linked\./);
assert.match(html, /Sign in to Azure to view and manage its connectors\./);
assert.doesNotMatch(html, /Couldn't load the saved namespace|couldn't open namespace/i); assert.doesNotMatch(html, /Couldn't load the saved namespace|couldn't open namespace/i);
}); });
+6 -11
View File
@@ -20,18 +20,13 @@ MCP server issue locally.
## Prerequisites ## Prerequisites
1. **A browser for Microsoft Entra sign-in.** The harness opens the same 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 interactive Azure sign-in as the extension at the start of each process.
session is not already available.
2. **A gateway already picked once.** The harness reads gateway coordinates from 2. **A gateway already picked once.** The harness reads gateway coordinates from
`~/.copilot/extensions/connector-namespaces/artifacts/gateway-config.json` `~/.copilot/extensions/connector-namespaces/artifacts/gateway-config.json`
(`{ subscriptionId, resourceGroup, gatewayName }`). Pick a gateway once in (`{ subscriptionId, resourceGroup, gatewayName }`). Pick a gateway once in
the connector-namespaces canvas, or write that file by hand. the connector-namespaces canvas, or write that file by hand.
3. **An operating-system secure credential store.** Windows and macOS provide one 3. **Node 20+** (developed on Node 24).
by default. Linux and WSL require a Secret Service-compatible keyring, such as 4. **Extension dependencies installed.** From the repository root, run:
GNOME Keyring, with `libsecret` available. Unencrypted token storage is
intentionally disabled.
4. **Node 20+** (developed on Node 24).
5. **Extension dependencies installed.** From the repository root, run:
```bash ```bash
npm install --prefix extensions/connector-namespaces npm install --prefix extensions/connector-namespaces
@@ -65,9 +60,9 @@ node extensions/connector-namespaces/test/smoke.mjs --limit=5 --open-consent
## One-time connector consent ## One-time connector consent
OAuth-backed servers (most of them) need a human to consent **once** in a 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 browser. Azure ARM sign-in is process-memory only and is repeated for each
credential store described in the prerequisites; the one-time behavior below harness process; the one-time behavior below applies to the connector's own
applies to the connector's own consent. The model: consent. The model:
1. **First run** hits a server that needs consent → the harness prints a consent 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 URL and marks it `NEEDS_CONSENT`. It saves a pending record to