Migrated to TypeScript.
This commit is contained in:
@@ -2,7 +2,18 @@
|
||||
|
||||
import { DefaultAzureCredential, ClientSecretCredential, DeviceCodeCredential } from "@azure/identity";
|
||||
|
||||
export async function getCredential(credentialType, options) {
|
||||
type CredentialType = "d" | "default" | "cs" | "clientSecret" | "dc" | "deviceCode";
|
||||
|
||||
type CredentialOptions = {
|
||||
tenantId?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
};
|
||||
|
||||
export async function getCredential(
|
||||
credentialType: CredentialType,
|
||||
options: CredentialOptions,
|
||||
): Promise<DefaultAzureCredential | ClientSecretCredential | DeviceCodeCredential> {
|
||||
switch (credentialType) {
|
||||
case "d":
|
||||
case "default":
|
||||
1
src/azure/index.d.ts
vendored
1
src/azure/index.d.ts
vendored
@@ -1 +0,0 @@
|
||||
//
|
||||
@@ -2,12 +2,11 @@
|
||||
|
||||
/**
|
||||
* @module azure
|
||||
*
|
||||
*
|
||||
* This module provides authentication functionalities for Azure services.
|
||||
*
|
||||
*/
|
||||
|
||||
export { getCredential } from "./client-auth.js";
|
||||
export { getCredential } from "./client-auth.ts";
|
||||
export {
|
||||
loginInteractive,
|
||||
loginDeviceCode,
|
||||
@@ -15,4 +14,4 @@ export {
|
||||
logout,
|
||||
parseResources,
|
||||
acquireResourceTokenFromLogin,
|
||||
} from "./pca-auth.js";
|
||||
} from "./pca-auth.ts";
|
||||
@@ -6,20 +6,76 @@ import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { PublicClientApplication } from "@azure/msal-node";
|
||||
import type {
|
||||
AccountInfo,
|
||||
AuthenticationResult,
|
||||
ICachePlugin,
|
||||
TokenCacheContext,
|
||||
} from "@azure/msal-node";
|
||||
import os from "node:os";
|
||||
|
||||
const RESOURCE_SCOPE_BY_NAME = {
|
||||
graph: "https://graph.microsoft.com/.default",
|
||||
devops: "499b84ac-1321-427f-aa17-267ca6975798/.default",
|
||||
arm: "https://management.azure.com/.default",
|
||||
};
|
||||
} as const;
|
||||
|
||||
const DEFAULT_RESOURCES = ["graph", "devops", "arm"];
|
||||
type ResourceName = keyof typeof RESOURCE_SCOPE_BY_NAME;
|
||||
|
||||
const DEFAULT_RESOURCES: ResourceName[] = ["graph", "devops", "arm"];
|
||||
const LOGIN_REQUIRED_MESSAGE = "Login required. Run: sk-az-tools login";
|
||||
const BROWSER_KEYWORDS = Object.keys(apps).sort();
|
||||
const OPEN_APPS = apps as Record<string, string | readonly string[]>;
|
||||
const CHROMIUM_BROWSERS = new Set(["edge", "chrome", "brave"]);
|
||||
|
||||
function getCacheRoot() {
|
||||
type SessionState = {
|
||||
activeAccountUpn: string | null;
|
||||
};
|
||||
|
||||
type BrowserOptions = {
|
||||
browser?: string;
|
||||
browserProfile?: string;
|
||||
};
|
||||
|
||||
type LoginInteractiveOptions = {
|
||||
tenantId?: string;
|
||||
clientId?: string;
|
||||
scopes: string[];
|
||||
showAuthUrlOnly?: boolean;
|
||||
browser?: string;
|
||||
browserProfile?: string;
|
||||
};
|
||||
|
||||
type LoginDeviceCodeOptions = {
|
||||
tenantId?: string;
|
||||
clientId?: string;
|
||||
scopes: string[];
|
||||
};
|
||||
|
||||
type LoginOptions = {
|
||||
tenantId?: string;
|
||||
clientId?: string;
|
||||
resourcesCsv?: string;
|
||||
useDeviceCode?: boolean;
|
||||
noBrowser?: boolean;
|
||||
browser?: string;
|
||||
browserProfile?: string;
|
||||
};
|
||||
|
||||
type AcquireResourceTokenOptions = {
|
||||
tenantId?: string;
|
||||
clientId?: string;
|
||||
resource?: string;
|
||||
};
|
||||
|
||||
type LogoutOptions = {
|
||||
tenantId?: string;
|
||||
clientId?: string;
|
||||
clearAll?: boolean;
|
||||
userPrincipalName?: string;
|
||||
};
|
||||
|
||||
function getCacheRoot(): string {
|
||||
const isWindows = process.platform === "win32";
|
||||
const userRoot = isWindows
|
||||
? process.env.LOCALAPPDATA || os.homedir()
|
||||
@@ -30,14 +86,14 @@ function getCacheRoot() {
|
||||
: path.join(userRoot, ".config", "sk-az-tools");
|
||||
}
|
||||
|
||||
function getSessionFilePath() {
|
||||
function getSessionFilePath(): string {
|
||||
return path.join(getCacheRoot(), "login-session.json");
|
||||
}
|
||||
|
||||
async function readSessionState() {
|
||||
async function readSessionState(): Promise<SessionState> {
|
||||
try {
|
||||
const sessionJson = await readFile(getSessionFilePath(), "utf8");
|
||||
const parsed = JSON.parse(sessionJson);
|
||||
const parsed = JSON.parse(sessionJson) as { activeAccountUpn?: unknown };
|
||||
return {
|
||||
activeAccountUpn:
|
||||
typeof parsed?.activeAccountUpn === "string"
|
||||
@@ -45,40 +101,40 @@ async function readSessionState() {
|
||||
: null,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err?.code === "ENOENT") {
|
||||
if ((err as { code?: string } | null)?.code === "ENOENT") {
|
||||
return { activeAccountUpn: null };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSessionState(state) {
|
||||
async function writeSessionState(state: SessionState): Promise<void> {
|
||||
const sessionPath = getSessionFilePath();
|
||||
await mkdir(path.dirname(sessionPath), { recursive: true });
|
||||
await writeFile(sessionPath, JSON.stringify(state, null, 2), "utf8");
|
||||
}
|
||||
|
||||
async function clearSessionState() {
|
||||
async function clearSessionState(): Promise<void> {
|
||||
try {
|
||||
await unlink(getSessionFilePath());
|
||||
} catch (err) {
|
||||
if (err?.code !== "ENOENT") {
|
||||
if ((err as { code?: string } | null)?.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUpn(upn) {
|
||||
function normalizeUpn(upn: unknown): string {
|
||||
return typeof upn === "string" ? upn.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function writeStderr(message) {
|
||||
function writeStderr(message: string): void {
|
||||
process.stderr.write(`${message}\n`);
|
||||
}
|
||||
|
||||
function getBrowserAppName(browser) {
|
||||
function getBrowserAppName(browser?: string): string | readonly string[] | undefined {
|
||||
if (!browser || browser.trim() === "") {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const keyword = BROWSER_KEYWORDS.find(
|
||||
@@ -90,10 +146,10 @@ function getBrowserAppName(browser) {
|
||||
);
|
||||
}
|
||||
|
||||
return apps[keyword];
|
||||
return OPEN_APPS[keyword];
|
||||
}
|
||||
|
||||
function getBrowserKeyword(browser) {
|
||||
function getBrowserKeyword(browser?: string): string {
|
||||
if (!browser || browser.trim() === "") {
|
||||
return "";
|
||||
}
|
||||
@@ -109,14 +165,13 @@ function getBrowserKeyword(browser) {
|
||||
return keyword.toLowerCase();
|
||||
}
|
||||
|
||||
function getBrowserOpenOptions({ browser, browserProfile }) {
|
||||
function getBrowserOpenOptions({ browser, browserProfile }: BrowserOptions): Parameters<typeof open>[1] {
|
||||
const browserName = getBrowserAppName(browser);
|
||||
const options = browserName
|
||||
? { wait: false, app: { name: browserName } }
|
||||
: { wait: false };
|
||||
|
||||
if (!browserProfile || browserProfile.trim() === "") {
|
||||
return options;
|
||||
return browserName
|
||||
? { wait: false, app: { name: browserName } }
|
||||
: { wait: false };
|
||||
}
|
||||
|
||||
const browserKeyword = getBrowserKeyword(browser);
|
||||
@@ -126,11 +181,20 @@ function getBrowserOpenOptions({ browser, browserProfile }) {
|
||||
);
|
||||
}
|
||||
|
||||
options.app.arguments = [`--profile-directory=${browserProfile.trim()}`];
|
||||
return options;
|
||||
if (!browserName) {
|
||||
throw new Error("--browser-profile requires --browser");
|
||||
}
|
||||
|
||||
return {
|
||||
wait: false,
|
||||
app: {
|
||||
name: browserName,
|
||||
arguments: [`--profile-directory=${browserProfile.trim()}`],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validateBrowserOptions({ browser, browserProfile }) {
|
||||
function validateBrowserOptions({ browser, browserProfile }: BrowserOptions): void {
|
||||
if (browser && browser.trim() !== "") {
|
||||
getBrowserAppName(browser);
|
||||
}
|
||||
@@ -145,7 +209,7 @@ function validateBrowserOptions({ browser, browserProfile }) {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseResources(resourcesCsv) {
|
||||
export function parseResources(resourcesCsv?: string): ResourceName[] {
|
||||
if (!resourcesCsv || resourcesCsv.trim() === "") {
|
||||
return [...DEFAULT_RESOURCES];
|
||||
}
|
||||
@@ -156,24 +220,24 @@ export function parseResources(resourcesCsv) {
|
||||
.filter(Boolean);
|
||||
|
||||
const unique = [...new Set(resources)];
|
||||
const invalid = unique.filter((name) => !RESOURCE_SCOPE_BY_NAME[name]);
|
||||
const invalid = unique.filter((name) => !Object.prototype.hasOwnProperty.call(RESOURCE_SCOPE_BY_NAME, name));
|
||||
if (invalid.length > 0) {
|
||||
throw new Error(
|
||||
`Invalid resource name(s): ${invalid.join(", ")}. Allowed: ${DEFAULT_RESOURCES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return unique;
|
||||
return unique as ResourceName[];
|
||||
}
|
||||
|
||||
function fileCachePlugin(cachePath) {
|
||||
function fileCachePlugin(cachePath: string): ICachePlugin {
|
||||
return {
|
||||
beforeCacheAccess: async (ctx) => {
|
||||
beforeCacheAccess: async (ctx: TokenCacheContext) => {
|
||||
if (fs.existsSync(cachePath)) {
|
||||
ctx.tokenCache.deserialize(fs.readFileSync(cachePath, "utf8"));
|
||||
}
|
||||
},
|
||||
afterCacheAccess: async (ctx) => {
|
||||
afterCacheAccess: async (ctx: TokenCacheContext) => {
|
||||
if (!ctx.cacheHasChanged) return;
|
||||
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
||||
fs.writeFileSync(cachePath, ctx.tokenCache.serialize());
|
||||
@@ -182,10 +246,10 @@ function fileCachePlugin(cachePath) {
|
||||
};
|
||||
}
|
||||
|
||||
async function createPca({ tenantId, clientId }) {
|
||||
async function createPca({ tenantId, clientId }: { tenantId: string; clientId: string }): Promise<PublicClientApplication> {
|
||||
const cacheRoot = getCacheRoot();
|
||||
const cachePath = path.join(cacheRoot, `${clientId}-msal.cache`);
|
||||
let cachePlugin;
|
||||
let cachePlugin: ICachePlugin;
|
||||
try {
|
||||
const {
|
||||
DataProtectionScope,
|
||||
@@ -201,7 +265,7 @@ async function createPca({ tenantId, clientId }) {
|
||||
usePlaintextFileOnLinux: true,
|
||||
});
|
||||
cachePlugin = new PersistenceCachePlugin(persistence);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// Fallback when msal-node-extensions/keytar/libsecret are unavailable.
|
||||
cachePlugin = fileCachePlugin(cachePath);
|
||||
}
|
||||
@@ -217,7 +281,15 @@ async function createPca({ tenantId, clientId }) {
|
||||
});
|
||||
}
|
||||
|
||||
async function acquireTokenWithCache({ pca, scopes, account }) {
|
||||
async function acquireTokenWithCache({
|
||||
pca,
|
||||
scopes,
|
||||
account,
|
||||
}: {
|
||||
pca: PublicClientApplication;
|
||||
scopes: string[];
|
||||
account?: AccountInfo | null;
|
||||
}): Promise<AuthenticationResult | null> {
|
||||
if (account) {
|
||||
try {
|
||||
return await pca.acquireTokenSilent({
|
||||
@@ -244,7 +316,13 @@ async function acquireTokenWithCache({ pca, scopes, account }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findAccountByUpn({ pca, upn }) {
|
||||
async function findAccountByUpn({
|
||||
pca,
|
||||
upn,
|
||||
}: {
|
||||
pca: PublicClientApplication;
|
||||
upn: string | null;
|
||||
}): Promise<AccountInfo | null> {
|
||||
const normalized = normalizeUpn(upn);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
@@ -264,11 +342,12 @@ export async function loginInteractive({
|
||||
showAuthUrlOnly = false,
|
||||
browser,
|
||||
browserProfile,
|
||||
}) {
|
||||
}: LoginInteractiveOptions): Promise<AuthenticationResult | null> {
|
||||
if (!tenantId) throw new Error("tenantId is required");
|
||||
if (!clientId) throw new Error("clientId is required");
|
||||
if (!Array.isArray(scopes) || scopes.length === 0)
|
||||
if (!Array.isArray(scopes) || scopes.length === 0) {
|
||||
throw new Error("scopes[] is required");
|
||||
}
|
||||
validateBrowserOptions({ browser, browserProfile });
|
||||
|
||||
const pca = await createPca({ tenantId, clientId });
|
||||
@@ -276,33 +355,34 @@ export async function loginInteractive({
|
||||
const cached = await acquireTokenWithCache({ pca, scopes });
|
||||
if (cached) return cached;
|
||||
|
||||
return await pca.acquireTokenInteractive({
|
||||
return pca.acquireTokenInteractive({
|
||||
scopes,
|
||||
openBrowser: async (url) => {
|
||||
openBrowser: async (url: string) => {
|
||||
if (showAuthUrlOnly) {
|
||||
writeStderr(`Visit:\n${url}`);
|
||||
return;
|
||||
}
|
||||
const options = getBrowserOpenOptions({ browser, browserProfile });
|
||||
return open(url, options).catch(() => {
|
||||
await open(url, options).catch(() => {
|
||||
writeStderr(`Visit:\n${url}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function loginDeviceCode({ tenantId, clientId, scopes }) {
|
||||
export async function loginDeviceCode({ tenantId, clientId, scopes }: LoginDeviceCodeOptions): Promise<AuthenticationResult | null> {
|
||||
if (!tenantId) throw new Error("tenantId is required");
|
||||
if (!clientId) throw new Error("clientId is required");
|
||||
if (!Array.isArray(scopes) || scopes.length === 0)
|
||||
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({
|
||||
return pca.acquireTokenByDeviceCode({
|
||||
scopes,
|
||||
deviceCodeCallback: (response) => {
|
||||
writeStderr(response.message);
|
||||
@@ -318,7 +398,12 @@ export async function login({
|
||||
noBrowser = false,
|
||||
browser,
|
||||
browserProfile,
|
||||
}) {
|
||||
}: LoginOptions): Promise<{
|
||||
accountUpn: string | null;
|
||||
resources: Array<{ resource: string; expiresOn: string | null }>;
|
||||
flow: "device-code" | "interactive";
|
||||
browserLaunchAttempted: boolean;
|
||||
}> {
|
||||
if (!tenantId) throw new Error("tenantId is required");
|
||||
if (!clientId) throw new Error("clientId is required");
|
||||
validateBrowserOptions({ browser, browserProfile });
|
||||
@@ -332,8 +417,8 @@ export async function login({
|
||||
upn: session.activeAccountUpn,
|
||||
});
|
||||
|
||||
const results = [];
|
||||
let selectedAccount = preferredAccount;
|
||||
const results: Array<{ resource: string; expiresOn: string | null }> = [];
|
||||
let selectedAccount: AccountInfo | null = preferredAccount;
|
||||
for (let index = 0; index < resources.length; index += 1) {
|
||||
const resource = resources[index];
|
||||
const scope = [scopes[index]];
|
||||
@@ -354,13 +439,13 @@ export async function login({
|
||||
} else {
|
||||
token = await pca.acquireTokenInteractive({
|
||||
scopes: scope,
|
||||
openBrowser: async (url) => {
|
||||
openBrowser: async (url: string) => {
|
||||
if (noBrowser) {
|
||||
writeStderr(`Visit:\n${url}`);
|
||||
return;
|
||||
}
|
||||
const options = getBrowserOpenOptions({ browser, browserProfile });
|
||||
return open(url, options).catch(() => {
|
||||
await open(url, options).catch(() => {
|
||||
writeStderr(`Visit:\n${url}`);
|
||||
});
|
||||
},
|
||||
@@ -395,16 +480,17 @@ export async function acquireResourceTokenFromLogin({
|
||||
tenantId,
|
||||
clientId,
|
||||
resource,
|
||||
}) {
|
||||
}: AcquireResourceTokenOptions): Promise<AuthenticationResult | null> {
|
||||
if (!tenantId) throw new Error("tenantId is required");
|
||||
if (!clientId) throw new Error("clientId is required");
|
||||
if (!resource) throw new Error("resource is required");
|
||||
|
||||
const scope = RESOURCE_SCOPE_BY_NAME[resource];
|
||||
if (!scope) {
|
||||
if (!Object.prototype.hasOwnProperty.call(RESOURCE_SCOPE_BY_NAME, resource)) {
|
||||
throw new Error(`Invalid resource '${resource}'. Allowed: ${DEFAULT_RESOURCES.join(", ")}`);
|
||||
}
|
||||
|
||||
const scope = RESOURCE_SCOPE_BY_NAME[resource as ResourceName];
|
||||
|
||||
const session = await readSessionState();
|
||||
if (!session.activeAccountUpn) {
|
||||
throw new Error(LOGIN_REQUIRED_MESSAGE);
|
||||
@@ -434,7 +520,7 @@ export async function logout({
|
||||
clientId,
|
||||
clearAll = false,
|
||||
userPrincipalName,
|
||||
}) {
|
||||
}: LogoutOptions): Promise<{ clearedAll: boolean; signedOut: string[] }> {
|
||||
if (!tenantId) throw new Error("tenantId is required");
|
||||
if (!clientId) throw new Error("clientId is required");
|
||||
|
||||
@@ -450,7 +536,7 @@ export async function logout({
|
||||
await clearSessionState();
|
||||
return {
|
||||
clearedAll: true,
|
||||
signedOut: accounts.map((account) => account.username).filter(Boolean),
|
||||
signedOut: accounts.map((account) => account.username).filter((name): name is string => Boolean(name)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -468,6 +554,6 @@ export async function logout({
|
||||
await clearSessionState();
|
||||
return {
|
||||
clearedAll: false,
|
||||
signedOut: [accountToSignOut.username].filter(Boolean),
|
||||
signedOut: [accountToSignOut.username].filter((name): name is string => Boolean(name)),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user