Files
awesome-copilot/extensions/connector-namespaces/auth.mjs
T
Alex Yang [MSFT] c0869f6231 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>
2026-07-23 10:51:00 -07:00

237 lines
8.7 KiB
JavaScript

import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { InteractiveBrowserCredential } from "@azure/identity";
export const ARM_SCOPE = "https://management.azure.com/.default";
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 legacyAuthArtifactsRemoved = false;
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}`);
}
}
}
legacyAuthArtifactsRemoved = 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 () => {},
now = Date.now,
scope = ARM_SCOPE,
} = {}) {
this.createCredential = createCredential;
this.createSessionId = createSessionId;
this.cleanupLegacyCredentials = cleanupLegacyCredentials;
this.now = now;
this.scope = scope;
this.credential = null;
this.accessToken = null;
this.cleanupInFlight = null;
this.tokenInFlight = null;
this.sessions = new Map();
}
createInteractiveCredential() {
return this.createCredential({
redirectUri: "http://localhost",
disableAutomaticAuthentication: true,
});
}
ensureLegacyCredentialsRemoved() {
if (!this.cleanupInFlight) {
const cleanup = Promise.resolve()
.then(() => this.cleanupLegacyCredentials())
.catch((error) => {
if (this.cleanupInFlight === cleanup) this.cleanupInFlight = null;
throw error;
});
this.cleanupInFlight = cleanup;
}
return this.cleanupInFlight;
}
pruneSessions() {
const cutoff = this.now() - SIGN_IN_SESSION_TTL_MS;
for (const [sessionId, session] of this.sessions) {
if (session.createdAt >= cutoff) continue;
session.status = "cancelled";
session.abortController.abort();
this.sessions.delete(sessionId);
}
}
startSignIn() {
this.pruneSessions();
const sessionId = this.createSessionId();
const abortController = new AbortController();
let credential;
try {
credential = this.createInteractiveCredential();
} catch (error) {
return { ok: false, reason: "identity_unavailable", error: errorDetail(error) };
}
const session = {
abortController,
createdAt: this.now(),
error: "",
status: "pending",
};
this.sessions.set(sessionId, session);
session.promise = Promise.resolve()
.then(async () => {
await this.ensureLegacyCredentialsRemoved();
const authenticationRecord = await credential.authenticate(
this.scope,
{ abortSignal: abortController.signal },
);
if (!authenticationRecord) {
throw new Error("Azure identity did not return an authentication record.");
}
const accessToken = await credential.getToken(this.scope, { abortSignal: abortController.signal });
if (!accessToken?.token || !Number.isFinite(accessToken.expiresOnTimestamp)) {
throw new Error("Azure identity returned an incomplete ARM access token.");
}
if (session.status !== "pending" || this.sessions.get(sessionId) !== session) return;
this.credential = credential;
this.accessToken = accessToken;
session.status = "done";
})
.catch((error) => {
if (session.status !== "pending") return;
session.status = abortController.signal.aborted ? "cancelled" : "error";
if (session.status === "error") session.error = errorDetail(error);
});
return { ok: true, sessionId, mode: "interactive" };
}
getSignInStatus(sessionId) {
this.pruneSessions();
const session = this.sessions.get(sessionId);
if (!session) return { ok: false, status: "unknown" };
if (session.status === "pending") return { ok: true, status: "pending", mode: "interactive" };
if (session.status === "done") return { ok: true, status: "done" };
if (session.status === "cancelled") return { ok: true, status: "cancelled" };
return { ok: false, status: "error", error: session.error || "Azure sign-in failed." };
}
cancelSignIn(sessionId) {
const session = this.sessions.get(sessionId);
if (!session || session.status !== "pending") return { ok: true };
session.status = "cancelled";
session.abortController.abort();
return { ok: true };
}
async getToken() {
await this.ensureLegacyCredentialsRemoved();
if (hasUsableToken(this.accessToken, this.now())) return this.accessToken.token;
if (this.tokenInFlight) return this.tokenInFlight;
const request = (async () => {
let credential = this.credential;
if (!credential) {
try {
credential = this.createInteractiveCredential();
this.credential = credential;
} catch (error) {
if (isAuthenticationRequiredError(error)) {
throw new ConnectorAuthenticationRequiredError(
"Sign in to Azure to continue.",
{ cause: error },
);
}
throw error;
}
}
try {
const accessToken = await credential.getToken(this.scope);
if (!accessToken?.token || !Number.isFinite(accessToken.expiresOnTimestamp)) {
throw new Error("Azure identity returned an incomplete ARM access token.");
}
this.accessToken = accessToken;
return accessToken.token;
} catch (error) {
if (!isAuthenticationRequiredError(error)) throw error;
if (this.credential === credential) {
this.credential = null;
this.accessToken = null;
}
throw new ConnectorAuthenticationRequiredError(
"Sign in to Azure to continue.",
{ cause: error },
);
}
})();
this.tokenInFlight = request;
try {
return await request;
} finally {
if (this.tokenInFlight === request) this.tokenInFlight = null;
}
}
}
export const interactiveAuth = new InteractiveAuthBroker({
cleanupLegacyCredentials: removeLegacyAuthArtifacts,
});
export const startSignIn = () => interactiveAuth.startSignIn();
export const getSignInStatus = (sessionId) => interactiveAuth.getSignInStatus(sessionId);
export const cancelSignIn = (sessionId) => interactiveAuth.cancelSignIn(sessionId);
export const getToken = () => interactiveAuth.getToken();