mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-01 23:12:29 +00:00
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:
@@ -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.
|
||||
- **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.
|
||||
- **Persistent namespace selection** - retain the selected namespace while Azure
|
||||
tokens remain in memory and sign-in is requested again after a restart.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -36,10 +36,6 @@ and select **Install in GitHub Copilot app**.
|
||||
- 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.
|
||||
|
||||
Connector Namespace is currently an Azure preview service and availability can
|
||||
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** 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.
|
||||
resources. Access and refresh tokens remain in the extension process and are
|
||||
never written to extension files. Reloading the extension or restarting the
|
||||
app requires Azure sign-in again. The selected namespace coordinates are
|
||||
retained in
|
||||
`~/.copilot/extensions/connector-namespaces/artifacts/gateway-config.json` so
|
||||
the canvas can explain that the namespace is still linked and return directly
|
||||
to its connectors after sign-in.
|
||||
- **Connector sign-in** grants an individual MCP server access to its backing
|
||||
service. The resulting connection is managed by Connector Namespace.
|
||||
|
||||
|
||||
@@ -2,16 +2,9 @@ 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";
|
||||
import { InteractiveBrowserCredential } from "@azure/identity";
|
||||
|
||||
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;
|
||||
@@ -24,50 +17,20 @@ const AUTH_STORAGE_DIR = join(
|
||||
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;
|
||||
let legacyAuthArtifactsRemoved = 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}`);
|
||||
async function removeLegacyAuthArtifacts() {
|
||||
if (legacyAuthArtifactsRemoved) return;
|
||||
for (const path of [AUTH_RECORD_FILE, LEGACY_AUTH_CACHE]) {
|
||||
try {
|
||||
await fs.unlink(path);
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") {
|
||||
throw new Error(`Could not remove the legacy connector authentication file at ${path}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
legacyAuthCacheRemoved = true;
|
||||
legacyAuthArtifactsRemoved = true;
|
||||
}
|
||||
|
||||
export class ConnectorAuthenticationRequiredError extends Error {
|
||||
@@ -103,20 +66,14 @@ export class InteractiveAuthBroker {
|
||||
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;
|
||||
@@ -124,15 +81,10 @@ export class InteractiveAuthBroker {
|
||||
this.sessions = new Map();
|
||||
}
|
||||
|
||||
createInteractiveCredential(authenticationRecord) {
|
||||
createInteractiveCredential() {
|
||||
return this.createCredential({
|
||||
redirectUri: "http://localhost",
|
||||
disableAutomaticAuthentication: true,
|
||||
tokenCachePersistenceOptions: {
|
||||
enabled: true,
|
||||
name: this.tokenCacheName,
|
||||
},
|
||||
...(authenticationRecord ? { authenticationRecord } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -188,7 +140,6 @@ export class InteractiveAuthBroker {
|
||||
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.");
|
||||
@@ -235,13 +186,8 @@ export class InteractiveAuthBroker {
|
||||
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;
|
||||
}
|
||||
credential = this.createInteractiveCredential();
|
||||
this.credential = credential;
|
||||
} catch (error) {
|
||||
if (isAuthenticationRequiredError(error)) {
|
||||
throw new ConnectorAuthenticationRequiredError(
|
||||
@@ -281,9 +227,7 @@ export class InteractiveAuthBroker {
|
||||
}
|
||||
|
||||
export const interactiveAuth = new InteractiveAuthBroker({
|
||||
cleanupLegacyCredentials: removeLegacyAuthCache,
|
||||
loadAuthRecord: loadAuthenticationRecord,
|
||||
saveAuthRecord: saveAuthenticationRecord,
|
||||
cleanupLegacyCredentials: removeLegacyAuthArtifacts,
|
||||
});
|
||||
|
||||
export const startSignIn = () => interactiveAuth.startSignIn();
|
||||
|
||||
@@ -4,8 +4,6 @@ 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) {
|
||||
@@ -35,7 +33,6 @@ test("ARM token requests require an explicit browser sign-in", async () => {
|
||||
},
|
||||
};
|
||||
},
|
||||
loadAuthRecord: async () => undefined,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
@@ -43,32 +40,13 @@ test("ARM token requests require an explicit browser sign-in", async () => {
|
||||
(error) => error instanceof ConnectorAuthenticationRequiredError
|
||||
&& error.code === "authentication_required",
|
||||
);
|
||||
assert.deepEqual(credentialOptions.tokenCachePersistenceOptions, {
|
||||
enabled: true,
|
||||
name: TOKEN_CACHE_NAME,
|
||||
assert.deepEqual(credentialOptions, {
|
||||
redirectUri: "http://localhost",
|
||||
disableAutomaticAuthentication: true,
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
test("interactive sign-in reports pending then done and keeps the ARM token in memory", async () => {
|
||||
let credentialOptions;
|
||||
let authenticateOptions;
|
||||
const credential = {
|
||||
@@ -89,7 +67,6 @@ test("interactive sign-in reports pending then done and caches the ARM token", a
|
||||
return credential;
|
||||
},
|
||||
createSessionId: () => "signin-session",
|
||||
saveAuthRecord: async (record) => assert.deepEqual(record, authenticationRecord()),
|
||||
now: () => 1_000,
|
||||
});
|
||||
|
||||
@@ -110,10 +87,6 @@ test("interactive sign-in reports pending then done and caches the ARM token", a
|
||||
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" });
|
||||
@@ -124,22 +97,24 @@ test("interactive sign-in reports pending then done and caches the ARM token", a
|
||||
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 };
|
||||
test("a new broker requires browser sign-in after the extension reloads", async () => {
|
||||
let authenticateCalls = 0;
|
||||
let createCredentialCalls = 0;
|
||||
const createCredential = (options) => {
|
||||
assert.deepEqual(options.tokenCachePersistenceOptions, {
|
||||
enabled: true,
|
||||
name: TOKEN_CACHE_NAME,
|
||||
createCredentialCalls++;
|
||||
assert.deepEqual(options, {
|
||||
redirectUri: "http://localhost",
|
||||
disableAutomaticAuthentication: true,
|
||||
});
|
||||
let signedIn = false;
|
||||
return {
|
||||
async authenticate() {
|
||||
authenticateCalls++;
|
||||
cache.token = accessToken("persisted-token");
|
||||
signedIn = true;
|
||||
return authenticationRecord();
|
||||
},
|
||||
async getToken() {
|
||||
if (cache.token && options.authenticationRecord) return cache.token;
|
||||
if (signedIn) return accessToken("memory-token");
|
||||
const error = new Error("No cached account found.");
|
||||
error.name = "AuthenticationRequiredError";
|
||||
throw error;
|
||||
@@ -149,41 +124,36 @@ test("a new broker restores the persisted credential without reopening the brows
|
||||
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);
|
||||
assert.equal(await signedInBroker.getToken(), "memory-token");
|
||||
|
||||
const restartedBroker = new InteractiveAuthBroker({
|
||||
createCredential,
|
||||
loadAuthRecord: async () => cache.record,
|
||||
now: () => 1_000,
|
||||
});
|
||||
assert.equal(await restartedBroker.getToken(), "persisted-token");
|
||||
await assert.rejects(restartedBroker.getToken(), ConnectorAuthenticationRequiredError);
|
||||
assert.equal(authenticateCalls, 1);
|
||||
assert.equal(createCredentialCalls, 2);
|
||||
});
|
||||
|
||||
test("concurrent first-time token requests share credential initialization and acquisition", async () => {
|
||||
let releaseAuthenticationRecord;
|
||||
const authenticationRecordReady = new Promise((resolve) => {
|
||||
releaseAuthenticationRecord = resolve;
|
||||
test("concurrent first-time token requests share credential acquisition", async () => {
|
||||
let releaseToken;
|
||||
const tokenReady = new Promise((resolve) => {
|
||||
releaseToken = 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++;
|
||||
await tokenReady;
|
||||
return accessToken("shared-token");
|
||||
},
|
||||
};
|
||||
@@ -193,15 +163,12 @@ test("concurrent first-time token requests share credential initialization and a
|
||||
const firstRequest = broker.getToken();
|
||||
const secondRequest = broker.getToken();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const loadsBeforeRelease = loadAuthRecordCalls;
|
||||
releaseAuthenticationRecord();
|
||||
releaseToken();
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -224,7 +191,6 @@ test("cancelling sign-in aborts the credential request", async () => {
|
||||
const broker = new InteractiveAuthBroker({
|
||||
createCredential: () => credential,
|
||||
createSessionId: () => "cancel-session",
|
||||
loadAuthRecord: async () => undefined,
|
||||
now: () => 1_000,
|
||||
});
|
||||
|
||||
@@ -289,7 +255,6 @@ test("legacy credential cleanup retries after a transient failure", async () =>
|
||||
return accessToken();
|
||||
},
|
||||
}),
|
||||
loadAuthRecord: async () => authenticationRecord(),
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -328,7 +292,6 @@ test("incomplete tokens remain operational errors instead of prompting sign-in",
|
||||
return { token: "incomplete" };
|
||||
},
|
||||
}),
|
||||
loadAuthRecord: async () => authenticationRecord(),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@azure/identity": "4.13.1",
|
||||
"@azure/identity-cache-persistence": "1.3.0",
|
||||
"@github/copilot-sdk": "1.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,14 +304,23 @@ 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 = "", { 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) =>
|
||||
`<option value="${s.id}">${esc(s.name)} (${s.id.slice(0, 8)}\u2026)</option>`
|
||||
).join("");
|
||||
|
||||
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Select Connector Namespace</title>${baseStyles()}
|
||||
<title>${pageTitle}</title>${baseStyles()}
|
||||
<style>
|
||||
.skeleton { animation: pulse 1.2s ease-in-out infinite; }
|
||||
@keyframes pulse { 0%,100% { opacity: .4; } 50% { opacity: .8; } }
|
||||
@@ -357,18 +366,18 @@ export function renderSetupHtml(subscriptions = [], notice = "", capabilityToken
|
||||
}
|
||||
</style></head><body>
|
||||
<div class="header brand-head">
|
||||
<h1>${brandMark(30, "setup")}<span>Select a Connector Namespace</span></h1>
|
||||
<div class="sub">Choose which connector namespace to browse. This choice is saved for future sessions.</div>
|
||||
<h1>${brandMark(30, "setup")}<span>${heading}</span></h1>
|
||||
<div class="sub">${subheading}</div>
|
||||
</div>
|
||||
${notice ? `<div class="setup-notice">${esc(notice)}</div>` : ""}
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div id="setup-content">
|
||||
<div id="setup-content"${hasLinkedNamespace ? " hidden" : ""}>
|
||||
<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>
|
||||
</div>
|
||||
@@ -386,6 +395,8 @@ ${notice ? `<div class="setup-notice">${esc(notice)}</div>` : ""}
|
||||
</div>
|
||||
<script>
|
||||
const connectorNamespaceToken = ${JSON.stringify(capabilityToken)};
|
||||
const hasLinkedNamespace = ${hasLinkedNamespace};
|
||||
const defaultSigninMessage = ${JSON.stringify(defaultSigninMessage)};
|
||||
const rawFetch = window.fetch.bind(window);
|
||||
window.fetch = (input, init = {}) => {
|
||||
const url = typeof input === "string" ? input : input && input.url;
|
||||
@@ -423,7 +434,7 @@ function setSigninRequired(message, state = "idle") {
|
||||
signinPanel.dataset.state = state;
|
||||
signinPanel.setAttribute("aria-busy", "false");
|
||||
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.hidden = false;
|
||||
cancelSigninButton.hidden = true;
|
||||
@@ -530,8 +541,14 @@ async function pollSignin() {
|
||||
stopSigninPolling();
|
||||
signinPanel.dataset.state = "pending";
|
||||
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;
|
||||
if (hasLinkedNamespace) {
|
||||
window.location.replace("/");
|
||||
return;
|
||||
}
|
||||
await loadSubscriptions(true);
|
||||
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>'; }
|
||||
}
|
||||
|
||||
if (${subscriptions.length > 0}) setSetupReady();
|
||||
if (hasLinkedNamespace) setSigninRequired();
|
||||
else if (${subscriptions.length > 0}) setSetupReady();
|
||||
else loadSubscriptions(false);
|
||||
</script></body></html>`;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,27 @@ test("setup prompts, polls, cancels, and reloads subscriptions after browser sig
|
||||
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<\/code><script>alert\("xss"\)<\/script>/);
|
||||
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", () => {
|
||||
const html = renderSetupHtml([], "", "token");
|
||||
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([
|
||||
readFile(new URL("auth.mjs", here), "utf8"),
|
||||
readFile(new URL("armClient.mjs", here), "utf8"),
|
||||
]);
|
||||
assert.match(authSource, /new InteractiveBrowserCredential\(options\)/);
|
||||
assert.match(authSource, /useIdentityPlugin\(cachePersistencePlugin\)/);
|
||||
assert.match(authSource, /disableAutomaticAuthentication: true/);
|
||||
assert.match(authSource, /tokenCachePersistenceOptions/);
|
||||
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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -561,7 +561,9 @@ async function handleRequest(req, res, instanceId, serverEntry) {
|
||||
} catch (err) {
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
if (isAuthenticationRequiredError(err)) {
|
||||
res.end(renderSetupHtml([], "", serverEntry.token));
|
||||
res.end(renderSetupHtml([], "", serverEntry.token, {
|
||||
linkedNamespace: config.gatewayName,
|
||||
}));
|
||||
} else {
|
||||
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 html = await response.text();
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -20,18 +20,13 @@ MCP server issue locally.
|
||||
## Prerequisites
|
||||
|
||||
1. **A browser for Microsoft Entra sign-in.** The harness opens the same
|
||||
interactive Azure sign-in as the extension when its encrypted Azure Identity
|
||||
session is not already available.
|
||||
interactive Azure sign-in as the extension at the start of each process.
|
||||
2. **A gateway already picked once.** The harness reads gateway coordinates from
|
||||
`~/.copilot/extensions/connector-namespaces/artifacts/gateway-config.json`
|
||||
(`{ subscriptionId, resourceGroup, gatewayName }`). Pick a gateway once in
|
||||
the connector-namespaces canvas, or write that file by hand.
|
||||
3. **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.
|
||||
4. **Node 20+** (developed on Node 24).
|
||||
5. **Extension dependencies installed.** From the repository root, run:
|
||||
3. **Node 20+** (developed on Node 24).
|
||||
4. **Extension dependencies installed.** From the repository root, run:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
OAuth-backed servers (most of them) need a human to consent **once** in a
|
||||
browser. Azure ARM sign-in is restored from the operating system's encrypted
|
||||
credential store described in the prerequisites; the one-time behavior below
|
||||
applies to the connector's own consent. The model:
|
||||
browser. Azure ARM sign-in is process-memory only and is repeated for each
|
||||
harness process; the one-time behavior below applies to the connector's own
|
||||
consent. The model:
|
||||
|
||||
1. **First run** hits a server that needs consent → the harness prints a consent
|
||||
URL and marks it `NEEDS_CONSENT`. It saves a pending record to
|
||||
|
||||
Reference in New Issue
Block a user