diff --git a/package-lock.json b/package-lock.json index 802fe90..84f5aa8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@slawek/sk-az-tools", - "version": "0.4.4", + "version": "0.4.5", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index fdcda4d..01e3dcc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@slawek/sk-az-tools", - "version": "0.4.4", + "version": "0.4.5", "type": "module", "files": [ "dist", diff --git a/src/azure/client-auth.ts b/src/azure/client-auth.ts index 6f782c5..8b3b633 100644 --- a/src/azure/client-auth.ts +++ b/src/azure/client-auth.ts @@ -1,18 +1,16 @@ // SPDX-License-Identifier: MIT import { DefaultAzureCredential, ClientSecretCredential, DeviceCodeCredential } from "@azure/identity"; +import type { AuthenticationResult } from "@azure/msal-node"; +import { acquireResourceToken as acquireResourceTokenPca } from "./pca-auth.ts"; type CredentialType = "d" | "default" | "cs" | "clientSecret" | "dc" | "deviceCode"; -type CredentialOptions = { - tenantId?: string; - clientId?: string; - clientSecret?: string; -}; - export async function getCredential( credentialType: CredentialType, - options: CredentialOptions, + tenantId?: string, + clientId?: string, + clientSecret?: string, ): Promise { switch (credentialType) { case "d": @@ -20,26 +18,26 @@ export async function getCredential( return new DefaultAzureCredential(); case "cs": case "clientSecret": - if (!options.tenantId || !options.clientId || !options.clientSecret) { + if (!tenantId || !clientId || !clientSecret) { throw new Error( "tenantId, clientId, and clientSecret are required for ClientSecretCredential", ); } return new ClientSecretCredential( - options.tenantId, - options.clientId, - options.clientSecret, + tenantId, + clientId, + clientSecret, ); case "dc": case "deviceCode": - if (!options.tenantId || !options.clientId) { + if (!tenantId || !clientId) { throw new Error( "tenantId and clientId are required for DeviceCodeCredential", ); } return new DeviceCodeCredential({ - tenantId: options.tenantId, - clientId: options.clientId, + tenantId, + clientId, userPromptCallback: (info) => { console.log(info.message); }, @@ -48,3 +46,11 @@ export async function getCredential( throw new Error(`Unsupported credential type: ${credentialType}`); } } + +export async function acquireResourceToken( + tenantId: string, + clientId: string, + resource: string, +): Promise { + return acquireResourceTokenPca(tenantId, clientId, resource); +} diff --git a/src/azure/index.ts b/src/azure/index.ts index 2bd1f74..0fb882f 100644 --- a/src/azure/index.ts +++ b/src/azure/index.ts @@ -7,11 +7,19 @@ */ export { getCredential } from "./client-auth.ts"; +import { acquireResourceToken as acquireResourceTokenPca } from "./pca-auth.ts"; export { loginInteractive, loginDeviceCode, login, logout, parseResources, - acquireResourceTokenFromLogin, } from "./pca-auth.ts"; + +export async function acquireResourceToken( + tenantId: string, + clientId: string, + resource: string, +) { + return acquireResourceTokenPca(tenantId, clientId, resource); +} diff --git a/src/azure/pca-auth.ts b/src/azure/pca-auth.ts index 37ff03c..d247696 100644 --- a/src/azure/pca-auth.ts +++ b/src/azure/pca-auth.ts @@ -13,7 +13,6 @@ import type { ICachePlugin, TokenCacheContext, } from "@azure/msal-node"; -import os from "node:os"; const RESOURCE_SCOPE_BY_NAME = { graph: "https://graph.microsoft.com/.default", @@ -34,60 +33,6 @@ 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() - : os.homedir(); - - return isWindows - ? path.join(userRoot, "sk-az-tools") - : path.join(userRoot, ".config", "sk-az-tools"); -} - async function readSessionState(): Promise { const parsed = (await getConfig("sk-az-tools", CONFIG_FILE_NAME)) as { activeAccountUpn?: unknown }; return { @@ -115,10 +60,6 @@ async function clearSessionState(): Promise { } } -function normalizeUpn(upn: unknown): string { - return typeof upn === "string" ? upn.trim().toLowerCase() : ""; -} - function writeStderr(message: string): void { process.stderr.write(`${message}\n`); } @@ -156,7 +97,7 @@ function getBrowserKeyword(browser?: string): string { return keyword.toLowerCase(); } -function getBrowserOpenOptions({ browser, browserProfile }: BrowserOptions): Parameters[1] { +function getBrowserOpenOptions(browser?: string, browserProfile?: string): Parameters[1] { const browserName = getBrowserAppName(browser); if (!browserProfile || browserProfile.trim() === "") { @@ -185,7 +126,7 @@ function getBrowserOpenOptions({ browser, browserProfile }: BrowserOptions): Par }; } -function validateBrowserOptions({ browser, browserProfile }: BrowserOptions): void { +function validateBrowserOptions(browser?: string, browserProfile?: string): void { if (browser && browser.trim() !== "") { getBrowserAppName(browser); } @@ -237,8 +178,8 @@ function fileCachePlugin(cachePath: string): ICachePlugin { }; } -async function createPca({ tenantId, clientId }: { tenantId: string; clientId: string }): Promise { - const cacheRoot = getCacheRoot(); +async function createPca(tenantId: string, clientId: string): Promise { + const cacheRoot = getConfigDir("sk-az-tools"); const cachePath = path.join(cacheRoot, `${clientId}-msal.cache`); let cachePlugin: ICachePlugin; try { @@ -272,15 +213,11 @@ async function createPca({ tenantId, clientId }: { tenantId: string; clientId: s }); } -async function acquireTokenWithCache({ - pca, - scopes, - account, -}: { - pca: PublicClientApplication; - scopes: string[]; - account?: AccountInfo | null; -}): Promise { +async function acquireTokenWithCache( + pca: PublicClientApplication, + scopes: string[], + account?: AccountInfo | null, +): Promise { if (account) { try { return await pca.acquireTokenSilent({ @@ -307,43 +244,40 @@ async function acquireTokenWithCache({ return null; } -async function findAccountByUpn({ - pca, - upn, -}: { - pca: PublicClientApplication; - upn: string | null; -}): Promise { - const normalized = normalizeUpn(upn); +async function findAccountByUpn( + pca: PublicClientApplication, + upn: string, +): Promise { + const normalized = upn.trim().toLowerCase(); if (!normalized) { return null; } const accounts = await pca.getTokenCache().getAllAccounts(); return ( - accounts.find((account) => normalizeUpn(account?.username) === normalized) ?? + accounts.find((account) => account.username.trim().toLowerCase() === normalized) ?? null ); } -export async function loginInteractive({ - tenantId, - clientId, - scopes, +export async function loginInteractive( + tenantId: string | undefined, + clientId: string | undefined, + scopes: string[], showAuthUrlOnly = false, - browser, - browserProfile, -}: LoginInteractiveOptions): Promise { + browser?: string, + browserProfile?: string, +): Promise { 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"); } - validateBrowserOptions({ browser, browserProfile }); + validateBrowserOptions(browser, browserProfile); - const pca = await createPca({ tenantId, clientId }); + const pca = await createPca(tenantId, clientId); - const cached = await acquireTokenWithCache({ pca, scopes }); + const cached = await acquireTokenWithCache(pca, scopes); if (cached) return cached; return pca.acquireTokenInteractive({ @@ -353,7 +287,7 @@ export async function loginInteractive({ writeStderr(`Visit:\n${url}`); return; } - const options = getBrowserOpenOptions({ browser, browserProfile }); + const options = getBrowserOpenOptions(browser, browserProfile); await open(url, options).catch(() => { writeStderr(`Visit:\n${url}`); }); @@ -361,16 +295,20 @@ export async function loginInteractive({ }); } -export async function loginDeviceCode({ tenantId, clientId, scopes }: LoginDeviceCodeOptions): Promise { +export async function loginDeviceCode( + tenantId: string | undefined, + clientId: string | undefined, + scopes: string[], +): Promise { 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 pca = await createPca(tenantId, clientId); - const cached = await acquireTokenWithCache({ pca, scopes }); + const cached = await acquireTokenWithCache(pca, scopes); if (cached) return cached; return pca.acquireTokenByDeviceCode({ @@ -381,15 +319,15 @@ export async function loginDeviceCode({ tenantId, clientId, scopes }: LoginDevic }); } -export async function login({ - tenantId, - clientId, - resourcesCsv, +export async function login( + tenantId: string | undefined, + clientId: string | undefined, + resourcesCsv?: string, useDeviceCode = false, noBrowser = false, - browser, - browserProfile, -}: LoginOptions): Promise<{ + browser?: string, + browserProfile?: string, +): Promise<{ accountUpn: string | null; resources: Array<{ resource: string; expiresOn: string | null }>; flow: "device-code" | "interactive"; @@ -397,27 +335,22 @@ export async function login({ }> { if (!tenantId) throw new Error("tenantId is required"); if (!clientId) throw new Error("clientId is required"); - validateBrowserOptions({ browser, browserProfile }); + validateBrowserOptions(browser, browserProfile); const resources = parseResources(resourcesCsv); const scopes = resources.map((resourceName) => RESOURCE_SCOPE_BY_NAME[resourceName]); - const pca = await createPca({ tenantId, clientId }); + const pca = await createPca(tenantId, clientId); const session = await readSessionState(); - const preferredAccount = await findAccountByUpn({ - pca, - upn: session.activeAccountUpn, - }); + const preferredAccount = session.activeAccountUpn + ? await findAccountByUpn(pca, session.activeAccountUpn) + : null; 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]]; - let token = await acquireTokenWithCache({ - pca, - scopes: scope, - account: selectedAccount, - }); + let token = await acquireTokenWithCache(pca, scope, selectedAccount); if (!token) { if (useDeviceCode) { @@ -435,7 +368,7 @@ export async function login({ writeStderr(`Visit:\n${url}`); return; } - const options = getBrowserOpenOptions({ browser, browserProfile }); + const options = getBrowserOpenOptions(browser, browserProfile); await open(url, options).catch(() => { writeStderr(`Visit:\n${url}`); }); @@ -467,11 +400,11 @@ export async function login({ }; } -export async function acquireResourceTokenFromLogin({ - tenantId, - clientId, - resource, -}: AcquireResourceTokenOptions): Promise { +export async function acquireResourceToken( + tenantId: string, + clientId: string, + resource: string, +): Promise { if (!tenantId) throw new Error("tenantId is required"); if (!clientId) throw new Error("clientId is required"); if (!resource) throw new Error("resource is required"); @@ -487,11 +420,8 @@ export async function acquireResourceTokenFromLogin({ throw new Error(LOGIN_REQUIRED_MESSAGE); } - const pca = await createPca({ tenantId, clientId }); - const account = await findAccountByUpn({ - pca, - upn: session.activeAccountUpn, - }); + const pca = await createPca(tenantId, clientId); + const account = await findAccountByUpn(pca, session.activeAccountUpn); if (!account) { throw new Error(LOGIN_REQUIRED_MESSAGE); } @@ -506,16 +436,16 @@ export async function acquireResourceTokenFromLogin({ } } -export async function logout({ - tenantId, - clientId, +export async function logout( + tenantId: string, + clientId: string, clearAll = false, - userPrincipalName, -}: LogoutOptions): Promise<{ clearedAll: boolean; signedOut: string[] }> { + userPrincipalName?: string, +): Promise<{ clearedAll: boolean; signedOut: string[] }> { if (!tenantId) throw new Error("tenantId is required"); if (!clientId) throw new Error("clientId is required"); - const pca = await createPca({ tenantId, clientId }); + const pca = await createPca(tenantId, clientId); const tokenCache = pca.getTokenCache(); const accounts = await tokenCache.getAllAccounts(); const session = await readSessionState(); @@ -531,9 +461,10 @@ export async function logout({ }; } - const targetUpn = normalizeUpn(userPrincipalName) || normalizeUpn(session.activeAccountUpn); + const targetUpn = (typeof userPrincipalName === "string" ? userPrincipalName.trim().toLowerCase() : "") + || (typeof session.activeAccountUpn === "string" ? session.activeAccountUpn.trim().toLowerCase() : ""); const accountToSignOut = accounts.find( - (account) => normalizeUpn(account.username) === targetUpn, + (account) => account.username.trim().toLowerCase() === targetUpn, ); if (!accountToSignOut) { diff --git a/src/cli/commands/get-token.ts b/src/cli/commands/get-token.ts index 776a4a1..35e189e 100644 --- a/src/cli/commands/get-token.ts +++ b/src/cli/commands/get-token.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT -import { acquireResourceTokenFromLogin } from "../../azure/index.ts"; +import { acquireResourceToken } from "../../azure/index.ts"; import { getDevOpsApiToken } from "../../devops/index.ts"; import { loadConfig } from "../../index.ts"; @@ -22,11 +22,11 @@ export async function runGetTokenCommand(values: CommandValues): Promise { const { client } = await getGraphClientFromPublicConfig(); - let result = await listApps(client, { - displayName: values["display-name"], - appId: values["app-id"], - }); + let result = await listApps(client, values["display-name"], values["app-id"]); if (values["app-id"] && result.length > 1) { throw new Error(`Expected a single app for --app-id ${values["app-id"]}, but got ${result.length}`); } diff --git a/src/cli/commands/list-resource-permissions.ts b/src/cli/commands/list-resource-permissions.ts index 2c9ae83..74ed4cb 100644 --- a/src/cli/commands/list-resource-permissions.ts +++ b/src/cli/commands/list-resource-permissions.ts @@ -23,10 +23,11 @@ export async function runListResourcePermissionsCommand(values: CommandValues): } const { client } = await getGraphClientFromPublicConfig(); - let result = await listResourcePermissions(client, { - appId: values["app-id"], - displayName: values["display-name"], - }); + let result = await listResourcePermissions( + client, + values["app-id"], + values["display-name"], + ); if (values.filter) { result = filterByPermissionName(result, values.filter); } diff --git a/src/cli/commands/login.ts b/src/cli/commands/login.ts index eeb7dc9..9d07140 100644 --- a/src/cli/commands/login.ts +++ b/src/cli/commands/login.ts @@ -18,13 +18,13 @@ Options: export async function runLoginCommand(values: CommandValues): Promise { const config = await loadConfig("public-config"); - return login({ - tenantId: config.tenantId, - clientId: config.clientId, - resourcesCsv: values.resources, - useDeviceCode: Boolean(values["use-device-code"]), - noBrowser: Boolean(values["no-browser"]), - browser: values.browser, - browserProfile: values["browser-profile"], - }); + return login( + config.tenantId, + config.clientId, + values.resources, + Boolean(values["use-device-code"]), + Boolean(values["no-browser"]), + values.browser, + values["browser-profile"], + ); } diff --git a/src/cli/commands/logout.ts b/src/cli/commands/logout.ts index 2304b19..229a174 100644 --- a/src/cli/commands/logout.ts +++ b/src/cli/commands/logout.ts @@ -14,9 +14,5 @@ Options: export async function runLogoutCommand(values: CommandValues): Promise { const config = await loadConfig("public-config"); - return logout({ - tenantId: config.tenantId, - clientId: config.clientId, - clearAll: Boolean(values.all), - }); + return logout(config.tenantId, config.clientId, Boolean(values.all)); } diff --git a/src/cli/commands/rest.ts b/src/cli/commands/rest.ts index 84608f6..32823ed 100644 --- a/src/cli/commands/rest.ts +++ b/src/cli/commands/rest.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT -import { acquireResourceTokenFromLogin } from "../../azure/index.ts"; +import { acquireResourceToken } from "../../azure/index.ts"; import { getDevOpsApiToken } from "../../devops/index.ts"; import { loadConfig } from "../../index.ts"; @@ -57,11 +57,11 @@ async function getAutoAuthorizationHeader(url: URL): Promise { const config = await loadConfig("public-config"); if (host === "management.azure.com") { - const result = await acquireResourceTokenFromLogin({ - tenantId: config.tenantId, - clientId: config.clientId, - resource: "arm", - }); + const result = await acquireResourceToken( + config.tenantId, + config.clientId, + "arm", + ); const accessToken = result?.accessToken; if (!accessToken) { throw new Error("Failed to obtain AzureRM token"); diff --git a/src/cli/commands/shared.ts b/src/cli/commands/shared.ts index bd931de..b3b8dc7 100644 --- a/src/cli/commands/shared.ts +++ b/src/cli/commands/shared.ts @@ -29,8 +29,5 @@ export function filterByDisplayName(rows: T[], pattern export async function getGraphClientFromPublicConfig(): Promise<{ client: any }> { const config = await loadConfig("public-config"); - return getGraphClient({ - tenantId: config.tenantId, - clientId: config.clientId, - }); + return getGraphClient(config.tenantId, config.clientId); } diff --git a/src/create-pca.ts b/src/create-pca.ts index e5de63c..b0bb915 100644 --- a/src/create-pca.ts +++ b/src/create-pca.ts @@ -8,21 +8,16 @@ import readline from "node:readline"; import { spawnSync } from "node:child_process"; import { parseArgs } from "node:util"; -type RunAzOptions = { - quiet?: boolean; - allowFailure?: boolean; -}; - type RunAzResult = { status: number; stdout: string; stderr: string; }; -function runAz(args: string[], options: RunAzOptions = {}): RunAzResult { +function runAz(args: string[], quiet = false, allowFailure = false): RunAzResult { const result = spawnSync("az", args, { encoding: "utf8", - stdio: options.quiet + stdio: quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "pipe", "pipe"], }); @@ -31,7 +26,7 @@ function runAz(args: string[], options: RunAzOptions = {}): RunAzResult { throw result.error; } - if (result.status !== 0 && options.allowFailure !== true) { + if (result.status !== 0 && allowFailure !== true) { throw new Error( (result.stderr || "").trim() || `az ${args.join(" ")} failed`, ); @@ -198,7 +193,7 @@ Options: "--enable-id-token-issuance", "true", ], - { quiet: true }, + true, ); } catch { console.error( @@ -210,14 +205,12 @@ Options: fs.rmSync(tempDir, { recursive: true, force: true }); } - runAz(["ad", "sp", "create", "--id", appId], { - quiet: true, - allowFailure: true, - }); + runAz(["ad", "sp", "create", "--id", appId], true, true); const adminConsentResult = runAz( ["ad", "app", "permission", "admin-consent", "--id", appId], - { quiet: true, allowFailure: true }, + true, + true, ); if (adminConsentResult.status !== 0) { console.warn( diff --git a/src/devops/index.ts b/src/devops/index.ts index 60fdda7..4006367 100644 --- a/src/devops/index.ts +++ b/src/devops/index.ts @@ -14,11 +14,11 @@ type LoginInteractiveResult = { }; export async function getDevOpsApiToken(tenantId: string, clientId: string): Promise { - const result = await loginInteractive({ + const result = await loginInteractive( tenantId, clientId, - scopes: AZURE_DEVOPS_SCOPES, - }) as LoginInteractiveResult; + AZURE_DEVOPS_SCOPES, + ) as LoginInteractiveResult; const accessToken = result?.accessToken; diff --git a/src/graph/app.ts b/src/graph/app.ts index 4fcb0ee..d60f64a 100644 --- a/src/graph/app.ts +++ b/src/graph/app.ts @@ -6,11 +6,6 @@ type GraphResult = { value?: T[]; }; -type AppQueryOptions = { - displayName?: string; - appId?: string; -}; - type RequiredResourceAccessItem = { type?: string; id?: string; @@ -38,11 +33,6 @@ type ServicePrincipal = { appRoles?: GraphPermission[]; }; -type ResourcePermissionsOptions = { - appId?: string; - displayName?: string; -}; - export async function getApp(client: any, displayName: string): Promise { const result = await client .api("/applications") @@ -68,8 +58,11 @@ export async function deleteApp(client: any, appObjectId: string): Promise await client.api(`/applications/${appObjectId}`).delete(); } -export async function listApps(client: any, options: AppQueryOptions = {}): Promise { - const { displayName, appId } = options; +export async function listApps( + client: any, + displayName?: string, + appId?: string, +): Promise { let request = client.api("/applications"); const filters: string[] = []; @@ -219,8 +212,11 @@ export async function listAppGrants(client: any, appId: string): Promise>> { - const { appId, displayName } = options; +export async function listResourcePermissions( + client: any, + appId?: string, + displayName?: string, +): Promise>> { if (!appId && !displayName) { throw new Error("appId or displayName is required"); } diff --git a/src/graph/auth.ts b/src/graph/auth.ts index 8469c92..0e7b568 100644 --- a/src/graph/auth.ts +++ b/src/graph/auth.ts @@ -1,24 +1,18 @@ // SPDX-License-Identifier: MIT import { Client } from "@microsoft/microsoft-graph-client"; -import { acquireResourceTokenFromLogin } from "../azure/index.ts"; - -type GraphClientOptions = { - tenantId?: string; - clientId?: string; -}; +import { acquireResourceToken } from "../azure/index.ts"; type GraphApiToken = { accessToken: string; [key: string]: unknown; }; -export async function getGraphClient({ tenantId, clientId }: GraphClientOptions): Promise<{ graphApiToken: GraphApiToken; client: any }> { - const graphApiToken = await acquireResourceTokenFromLogin({ - tenantId, - clientId, - resource: "graph", - }) as GraphApiToken; +export async function getGraphClient( + tenantId: string, + clientId: string, +): Promise<{ graphApiToken: GraphApiToken; client: any }> { + const graphApiToken = await acquireResourceToken(tenantId, clientId, "graph") as GraphApiToken; const client = Client.init({ authProvider: (done) => {