Update logout userPrincipalName

This commit is contained in:
2026-02-07 09:40:52 +01:00
parent 7c2228ec5b
commit aa75ad11f1
2 changed files with 105 additions and 121 deletions

View File

@@ -9,6 +9,7 @@
"dependencies": { "dependencies": {
"@azure/identity": "^4.13.0", "@azure/identity": "^4.13.0",
"@azure/msal-node": "^5.0.3", "@azure/msal-node": "^5.0.3",
"@azure/msal-node-extensions": "^1.2.0",
"@microsoft/microsoft-graph-client": "^3.0.7", "@microsoft/microsoft-graph-client": "^3.0.7",
"azure-devops-node-api": "^15.1.2" "azure-devops-node-api": "^15.1.2"
}, },

View File

@@ -1,39 +1,56 @@
import http from "node:http";
import { URL } from "node:url";
import open, { apps } from "open"; import open, { apps } from "open";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import os from "node:os";
import { PublicClientApplication } from "@azure/msal-node"; import { PublicClientApplication } from "@azure/msal-node";
import {
DataProtectionScope,
Environment,
PersistenceCachePlugin,
PersistenceCreator,
} from "@azure/msal-node-extensions";
function fileCachePlugin(cachePath) { async function createPca({ tenantId, clientId }) {
return { const cachePath = path.join(
beforeCacheAccess: async (ctx) => { Environment.getUserRootDirectory(),
if (fs.existsSync(cachePath)) { "sk-az-tools",
ctx.tokenCache.deserialize(fs.readFileSync(cachePath, "utf8")); `${clientId}-msal.cache`,
} );
const persistence = await PersistenceCreator.createPersistence({
cachePath,
dataProtectionScope: DataProtectionScope.CurrentUser,
serviceName: "sk-az-tools",
accountName: "msal-cache",
usePlaintextFileOnLinux: false,
});
return new PublicClientApplication({
auth: {
clientId,
authority: `https://login.microsoftonline.com/${tenantId}`,
}, },
afterCacheAccess: async (ctx) => { cache: {
if (ctx.cacheHasChanged) { cachePlugin: new PersistenceCachePlugin(persistence),
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
fs.writeFileSync(cachePath, ctx.tokenCache.serialize());
fs.chmodSync(cachePath, 0o600); // Owner read/write only
}
}, },
}; });
} }
function generatePkce() { async function acquireTokenWithCache({ pca, scopes }) {
const verifier = crypto.randomBytes(32).toString("base64url"); // 43 chars, valid const accounts = await pca.getTokenCache().getAllAccounts();
const challenge = crypto
.createHash("sha256")
.update(verifier)
.digest("base64url");
return { verifier, challenge, challengeMethod: "S256" }; if (accounts.length > 0) {
try {
return await pca.acquireTokenSilent({
account: accounts[0],
scopes,
});
} catch (e) {
// proceed to interactive/device code login
}
}
return null;
} }
export async function loginInteractive({ tenantId, clientId, scopes }) { export async function loginInteractive({ tenantId, clientId, scopes }) {
@@ -42,105 +59,71 @@ export async function loginInteractive({ tenantId, clientId, scopes }) {
if (!Array.isArray(scopes) || scopes.length === 0) if (!Array.isArray(scopes) || scopes.length === 0)
throw new Error("scopes[] is required"); throw new Error("scopes[] is required");
const cachePath = path.join( const pca = await createPca({ tenantId, clientId });
os.homedir(),
`.config/sk-az-tools`,
`${clientId}-token-cache.json`,
);
const pca = new PublicClientApplication({ const cached = await acquireTokenWithCache({ pca, scopes });
auth: { if (cached) return cached;
clientId,
authority: `https://login.microsoftonline.com/${tenantId}`, return await pca.acquireTokenInteractive({
}, scopes,
cache: { openBrowser: async (url) => {
cachePlugin: fileCachePlugin(cachePath), try {
await open(url, { wait: false });
// To enforce Microsoft Edge instead of the default browser, use:
// await open(url, {
// wait: false,
// app: {
// name: apps.edge,
// },
// });
} catch {
// If auto-open fails, provide URL for manual copy/paste.
console.log("Visit:\n" + url);
}
}, },
}); });
}
export async function loginDeviceCode({ tenantId, clientId, scopes }) {
if (!tenantId) throw new Error("tenantId is required");
if (!clientId) throw new Error("clientId is required");
if (!Array.isArray(scopes) || scopes.length === 0)
throw new Error("scopes[] is required");
const pca = await createPca({ tenantId, clientId });
const cached = await acquireTokenWithCache({ pca, scopes });
if (cached) return cached;
return await pca.acquireTokenByDeviceCode({
scopes,
deviceCodeCallback: (response) => {
console.log(response.message);
},
});
}
export async function logout({ tenantId, clientId, userPrincipalName }) {
if (!tenantId) throw new Error("tenantId is required");
if (!clientId) throw new Error("clientId is required");
if (!userPrincipalName)
throw new Error("userPrincipalName is required");
const pca = await createPca({ tenantId, clientId });
const accounts = await pca.getTokenCache().getAllAccounts(); const accounts = await pca.getTokenCache().getAllAccounts();
const accountToSignOut = accounts.find(
(acct) =>
acct.username?.toLowerCase() === userPrincipalName.toLowerCase(),
);
if (accounts.length > 0) { if (!accountToSignOut) return false;
try {
const silentResult = await pca.acquireTokenSilent({
account: accounts[0],
scopes,
});
return silentResult;
} catch (e) {
// proceed to interactive login
}
}
const pkce = generatePkce(); pca.signOut({ account: accountToSignOut }).then(() => {
console.log(`Signed out ${userPrincipalName}`);
return new Promise((resolve, reject) => { return true;
let redirectUri; }).catch((err) => {
console.error(`Failed to sign out ${userPrincipalName}:`, err);
const server = http.createServer(async (req, res) => { return false;
try {
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
if (url.pathname !== "/callback") {
res.writeHead(404).end();
return;
}
const code = url.searchParams.get("code");
if (!code) {
res.writeHead(400).end("Missing authorization code");
server.close();
reject(new Error("Missing authorization code"));
return;
}
res.end("Authentication complete. You may close this tab.");
server.close();
const token = await pca.acquireTokenByCode({
code,
scopes,
redirectUri,
codeVerifier: pkce.verifier,
});
resolve(token);
} catch (e) {
try {
server.close();
} catch {}
reject(e);
}
});
server.listen(0, "127.0.0.1", async () => {
try {
const { port } = server.address();
redirectUri = `http://localhost:${port}/callback`;
console.log("Using redirectUri:", redirectUri);
const authUrl = await pca.getAuthCodeUrl({
scopes,
redirectUri,
codeChallenge: pkce.challenge,
codeChallengeMethod: pkce.challengeMethod,
});
try {
await open(authUrl, {
wait: false,
app: {
name: apps.edge, // Enforce using Microsoft Edge browser
},
});
} catch {}
console.log("Visit:\n" + authUrl);
} catch (e) {
try {
server.close();
} catch {}
reject(e);
}
});
}); });
} }