diff --git a/package.json b/package.json index 6d81f7e..09e07ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@slawek/sk-az-tools", - "version": "0.5.2", + "version": "0.6.0", "type": "module", "files": [ "dist", diff --git a/src/azure/client-auth.ts b/src/azure/client-auth.ts index 8b3b633..8d83473 100644 --- a/src/azure/client-auth.ts +++ b/src/azure/client-auth.ts @@ -1,17 +1,32 @@ // 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"; +import { + DefaultAzureCredential, + ClientSecretCredential, + DeviceCodeCredential, + getBearerTokenProvider, +} from "@azure/identity"; +import type { TokenCredential } from "@azure/core-auth"; +import { SkAzureCredential } from "./sk-credential.ts"; -type CredentialType = "d" | "default" | "cs" | "clientSecret" | "dc" | "deviceCode"; +import { translateResourceNamesToScopes } from "./index.ts"; -export async function getCredential( +type CredentialType = + | "d" + | "default" + | "cs" + | "clientSecret" + | "dc" + | "deviceCode" + | "sk" + | "skCredential"; + +export function getCredential( credentialType: CredentialType, tenantId?: string, clientId?: string, clientSecret?: string, -): Promise { +): TokenCredential { switch (credentialType) { case "d": case "default": @@ -23,11 +38,7 @@ export async function getCredential( "tenantId, clientId, and clientSecret are required for ClientSecretCredential", ); } - return new ClientSecretCredential( - tenantId, - clientId, - clientSecret, - ); + return new ClientSecretCredential(tenantId, clientId, clientSecret); case "dc": case "deviceCode": if (!tenantId || !clientId) { @@ -42,15 +53,33 @@ export async function getCredential( console.log(info.message); }, }); + case "sk": + case "skCredential": + if (!tenantId || !clientId) { + throw new Error( + "tenantId and clientId are required for SkAzureCredential", + ); + } + return new SkAzureCredential(tenantId, clientId); + default: throw new Error(`Unsupported credential type: ${credentialType}`); } } -export async function acquireResourceToken( +export async function getTokenUsingAzureIdentity( tenantId: string, clientId: string, - resource: string, -): Promise { - return acquireResourceTokenPca(tenantId, clientId, resource); + resources: string[], +): Promise { + const scopes = translateResourceNamesToScopes(resources); + const credential = getCredential("default", tenantId, clientId); + + const getBearerToken = getBearerTokenProvider(credential, scopes); + const accessToken = await getBearerToken(); + if (!accessToken) { + throw new Error("Failed to acquire access token with Azure Identity."); + } + + return accessToken; } diff --git a/src/azure/index.ts b/src/azure/index.ts index 0fb882f..f404b1c 100644 --- a/src/azure/index.ts +++ b/src/azure/index.ts @@ -6,8 +6,11 @@ * This module provides authentication functionalities for Azure services. */ -export { getCredential } from "./client-auth.ts"; -import { acquireResourceToken as acquireResourceTokenPca } from "./pca-auth.ts"; +import { getTokenUsingMsal } from "./pca-auth.ts"; +import { getTokenUsingAzureIdentity } from "./client-auth.ts"; +import { loadConfig } from "../index.ts"; + +// Reexporting functions and types from submodules export { loginInteractive, loginDeviceCode, @@ -16,10 +19,60 @@ export { parseResources, } from "./pca-auth.ts"; -export async function acquireResourceToken( +export { getCredential } from "./client-auth.ts"; + +export const RESOURCE_SCOPE_BY_NAME = { + graph: "https://graph.microsoft.com/.default", + devops: "499b84ac-1321-427f-aa17-267ca6975798/.default", + arm: "https://management.azure.com/.default", + openai: "https://cognitiveservices.azure.com/.default", +} as const; + +export type ResourceName = keyof typeof RESOURCE_SCOPE_BY_NAME; +export const DEFAULT_RESOURCES: ResourceName[] = ["graph", "devops", "arm"]; + +// A helper function to translate short resource names to their corresponding scopes +export function translateResourceNamesToScopes(resourceNames: string[]): string[] { + return resourceNames.map((name) => RESOURCE_SCOPE_BY_NAME[name as ResourceName]); +} + +// Generic utility functions +export type AuthMode = "azure-identity" | "msal"; + +export async function getAccessToken( tenantId: string, clientId: string, - resource: string, -) { - return acquireResourceTokenPca(tenantId, clientId, resource); + resources: string[] +): Promise { + const config = await loadConfig(); + if (config.authMode === "msal") { + const result = await getTokenUsingMsal(tenantId, clientId, resources); + if (!result?.accessToken) { + throw new Error("Failed to acquire access token with MSAL."); + } + return result.accessToken; + } else { + return getTokenUsingAzureIdentity(tenantId, clientId, resources); + } } + +// export function getAzureIdentityGraphAuthProvider( +// tenantId: string, +// clientId: string, +// ) { +// const credential = new DefaultAzureCredential({ +// tenantId, +// managedIdentityClientId: clientId, +// }); + +// const getBearerToken = getBearerTokenProvider( +// credential, +// "https://graph.microsoft.com/.default", +// ); + +// return (done: (error: Error | null, accessToken: string | null) => void) => { +// void getBearerToken() +// .then((token) => done(null, token)) +// .catch((err) => done(err as Error, null)); +// }; +// } \ No newline at end of file diff --git a/src/azure/pca-auth.ts b/src/azure/pca-auth.ts index d247696..a978347 100644 --- a/src/azure/pca-auth.ts +++ b/src/azure/pca-auth.ts @@ -14,15 +14,10 @@ import type { TokenCacheContext, } from "@azure/msal-node"; -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; +import type { ResourceName } from "../azure/index.ts"; +import { RESOURCE_SCOPE_BY_NAME, DEFAULT_RESOURCES } from "../azure/index.ts"; +import { translateResourceNamesToScopes } from "./index.ts"; -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; @@ -296,8 +291,8 @@ export async function loginInteractive( } export async function loginDeviceCode( - tenantId: string | undefined, - clientId: string | undefined, + tenantId: string, + clientId: string, scopes: string[], ): Promise { if (!tenantId) throw new Error("tenantId is required"); @@ -320,8 +315,8 @@ export async function loginDeviceCode( } export async function login( - tenantId: string | undefined, - clientId: string | undefined, + tenantId: string, + clientId: string, resourcesCsv?: string, useDeviceCode = false, noBrowser = false, @@ -338,7 +333,7 @@ export async function login( validateBrowserOptions(browser, browserProfile); const resources = parseResources(resourcesCsv); - const scopes = resources.map((resourceName) => RESOURCE_SCOPE_BY_NAME[resourceName]); + const scopes = translateResourceNamesToScopes(resources); const pca = await createPca(tenantId, clientId); const session = await readSessionState(); const preferredAccount = session.activeAccountUpn @@ -347,34 +342,30 @@ export async function login( 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, scope, selectedAccount); + let token = await acquireTokenWithCache(pca, scopes, selectedAccount); - if (!token) { - if (useDeviceCode) { - token = await pca.acquireTokenByDeviceCode({ - scopes: scope, - deviceCodeCallback: (response) => { - writeStderr(response.message); - }, - }); - } else { - token = await pca.acquireTokenInteractive({ - scopes: scope, - openBrowser: async (url: string) => { - if (noBrowser) { - writeStderr(`Visit:\n${url}`); - return; - } - const options = getBrowserOpenOptions(browser, browserProfile); - await open(url, options).catch(() => { - writeStderr(`Visit:\n${url}`); - }); - }, - }); - } + if (!token) { + if (useDeviceCode) { + token = await pca.acquireTokenByDeviceCode({ + scopes: scopes, + deviceCodeCallback: (response) => { + writeStderr(response.message); + }, + }); + } else { + token = await pca.acquireTokenInteractive({ + scopes: scopes, + openBrowser: async (url: string) => { + if (noBrowser) { + writeStderr(`Visit:\n${url}`); + return; + } + const options = getBrowserOpenOptions(browser, browserProfile); + await open(url, options).catch(() => { + writeStderr(`Visit:\n${url}`); + }); + }, + }); } if (token?.account) { @@ -382,7 +373,7 @@ export async function login( } results.push({ - resource, + resource: resources.join(","), expiresOn: token?.expiresOn?.toISOString?.() ?? null, }); } @@ -400,20 +391,14 @@ export async function login( }; } -export async function acquireResourceToken( +export async function getTokenUsingMsal( tenantId: string, clientId: string, - resource: string, + resources: 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"); - - 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]; + if (!resources || resources.length === 0) throw new Error("resources are required"); const session = await readSessionState(); if (!session.activeAccountUpn) { @@ -426,10 +411,13 @@ export async function acquireResourceToken( throw new Error(LOGIN_REQUIRED_MESSAGE); } + // Convert short names of scopes to full resource scopes + const scopes = resources.map((res) => RESOURCE_SCOPE_BY_NAME[res as ResourceName] || res); + try { return await pca.acquireTokenSilent({ account, - scopes: [scope], + scopes, }); } catch { throw new Error(LOGIN_REQUIRED_MESSAGE); diff --git a/src/azure/sk-credential.ts b/src/azure/sk-credential.ts new file mode 100644 index 0000000..eca6122 --- /dev/null +++ b/src/azure/sk-credential.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +import type { AccessToken, GetTokenOptions, TokenCredential } from "@azure/core-auth"; +import { getTokenUsingMsal } from "./pca-auth.ts"; + +export class SkAzureCredential implements TokenCredential { + constructor( + private tenantId: string, + private clientId: string, + ) {} + + async getToken( + scopes: string | string[], + _options?: GetTokenOptions, + ): Promise { + const resources = Array.isArray(scopes) ? scopes : [scopes]; + const result = await getTokenUsingMsal(this.tenantId, this.clientId, resources); + + if (!result?.accessToken) { + return null; + } + + return { + token: result.accessToken, + expiresOnTimestamp: result.expiresOn + ? result.expiresOn.getTime() + : Date.now() + 55 * 60 * 1000, + }; + } +} diff --git a/src/cli/commands/get-token.ts b/src/cli/commands/get-token.ts index 35e189e..682c9a7 100644 --- a/src/cli/commands/get-token.ts +++ b/src/cli/commands/get-token.ts @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT -import { acquireResourceToken } from "../../azure/index.ts"; +import { getAccessToken } from "../../azure/index.ts"; import { getDevOpsApiToken } from "../../devops/index.ts"; -import { loadConfig } from "../../index.ts"; +import { loadAuthConfig } from "../../index.ts"; import type { CommandValues } from "./types.ts"; @@ -13,22 +13,21 @@ Options: --type, -t Token type: azurerm|devops`; } -export async function runGetTokenCommand(values: CommandValues): Promise { +export async function runGetTokenCommand( + values: CommandValues, +): Promise { const tokenType = (values.type ?? "").toString().trim().toLowerCase(); if (!tokenType) { - throw new Error("--type is required for get-token (allowed: azurerm, devops)"); + throw new Error( + "--type is required for get-token (allowed: azurerm, devops)", + ); } - const config = await loadConfig("public-config"); + const config = await loadAuthConfig("public-config"); if (tokenType === "azurerm") { - const result = await acquireResourceToken( - config.tenantId, - config.clientId, - "arm", - ); + const accessToken = await getAccessToken(config.tenantId, config.clientId, ["arm"]); - const accessToken = result?.accessToken; if (!accessToken) { throw new Error("Failed to obtain AzureRM token"); } @@ -40,7 +39,10 @@ export async function runGetTokenCommand(values: CommandValues): Promise { @@ -37,7 +38,7 @@ export async function runListAppPermissionsCommand(values: CommandValues): Promi throw new Error("--app-id is required for list-app-permissions"); } - const { client } = await getGraphClientFromPublicConfig(); + const client = await getGraphClient(); let result: unknown = values.resolve || values.filter ? await listAppPermissionsResolved(client, values["app-id"]) : await listAppPermissions(client, values["app-id"]); diff --git a/src/cli/commands/list-apps.ts b/src/cli/commands/list-apps.ts index 41094f7..f55a90c 100644 --- a/src/cli/commands/list-apps.ts +++ b/src/cli/commands/list-apps.ts @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT import { listApps } from "../../graph/app.ts"; - -import { filterByDisplayName, getGraphClientFromPublicConfig } from "./shared.ts"; +import { filterByDisplayName } from "./shared.ts"; +import { getGraphClient } from "../../graph/index.ts"; import type { CommandValues } from "./types.ts"; export function usageListApps(): string { @@ -15,7 +15,8 @@ Options: } export async function runListAppsCommand(values: CommandValues): Promise { - const { client } = await getGraphClientFromPublicConfig(); + const client = await getGraphClient(); + 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 74ed4cb..251c31f 100644 --- a/src/cli/commands/list-resource-permissions.ts +++ b/src/cli/commands/list-resource-permissions.ts @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT import { listResourcePermissions } from "../../graph/app.ts"; - -import { filterByPermissionName, getGraphClientFromPublicConfig } from "./shared.ts"; +import { getGraphClient } from "../../graph/index.ts"; +import { filterByPermissionName } from "./shared.ts"; import type { CommandValues } from "./types.ts"; export function usageListResourcePermissions(): string { @@ -22,7 +22,7 @@ export async function runListResourcePermissionsCommand(values: CommandValues): throw new Error("Use either --app-id or --display-name for list-resource-permissions, not both"); } - const { client } = await getGraphClientFromPublicConfig(); + const client = await getGraphClient(); let result = await listResourcePermissions( client, values["app-id"], diff --git a/src/cli/commands/login.ts b/src/cli/commands/login.ts index 9d07140..b0a410a 100644 --- a/src/cli/commands/login.ts +++ b/src/cli/commands/login.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT import { login } from "../../azure/index.ts"; -import { loadConfig } from "../../index.ts"; +import { loadAuthConfig } from "../../index.ts"; import type { CommandValues } from "./types.ts"; @@ -17,7 +17,7 @@ Options: } export async function runLoginCommand(values: CommandValues): Promise { - const config = await loadConfig("public-config"); + const config = await loadAuthConfig("public-config"); return login( config.tenantId, config.clientId, diff --git a/src/cli/commands/logout.ts b/src/cli/commands/logout.ts index 229a174..838f1ad 100644 --- a/src/cli/commands/logout.ts +++ b/src/cli/commands/logout.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT import { logout } from "../../azure/index.ts"; -import { loadConfig } from "../../index.ts"; +import { loadAuthConfig } from "../../index.ts"; import type { CommandValues } from "./types.ts"; @@ -13,6 +13,6 @@ Options: } export async function runLogoutCommand(values: CommandValues): Promise { - const config = await loadConfig("public-config"); + const config = await loadAuthConfig("public-config"); return logout(config.tenantId, config.clientId, Boolean(values.all)); } diff --git a/src/cli/commands/rest.ts b/src/cli/commands/rest.ts index 32823ed..e5c53c1 100644 --- a/src/cli/commands/rest.ts +++ b/src/cli/commands/rest.ts @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT -import { acquireResourceToken } from "../../azure/index.ts"; +import { getAccessToken } from "../../azure/index.ts"; import { getDevOpsApiToken } from "../../devops/index.ts"; -import { loadConfig } from "../../index.ts"; +import { loadAuthConfig } from "../../index.ts"; import type { CommandValues } from "./types.ts"; @@ -19,7 +19,9 @@ Authorization is added automatically for: dev.azure.com Uses devops token`; } -function parseHeaderLine(header?: string): { name: string; value: string } | null { +function parseHeaderLine( + header?: string, +): { name: string; value: string } | null { if (!header || header.trim() === "") { return null; } @@ -54,15 +56,10 @@ async function getAutoAuthorizationHeader(url: URL): Promise { return null; } - const config = await loadConfig("public-config"); + const config = await loadAuthConfig("public-config"); if (host === "management.azure.com") { - const result = await acquireResourceToken( - config.tenantId, - config.clientId, - "arm", - ); - const accessToken = result?.accessToken; + const accessToken = await getAccessToken(config.tenantId, config.clientId, ["arm"]); if (!accessToken) { throw new Error("Failed to obtain AzureRM token"); } @@ -74,7 +71,8 @@ async function getAutoAuthorizationHeader(url: URL): Promise { } export async function runRestCommand(values: CommandValues): Promise { - const method = (values.method ?? "GET").toString().trim().toUpperCase() || "GET"; + const method = + (values.method ?? "GET").toString().trim().toUpperCase() || "GET"; const urlValue = (values.url ?? "").toString().trim(); if (!urlValue) { diff --git a/src/graph/auth.ts b/src/graph/auth.ts index 0e7b568..7ba86f3 100644 --- a/src/graph/auth.ts +++ b/src/graph/auth.ts @@ -1,24 +1,54 @@ // SPDX-License-Identifier: MIT import { Client } from "@microsoft/microsoft-graph-client"; -import { acquireResourceToken } from "../azure/index.ts"; +import { getAccessToken } from "../azure/index.ts"; +import { DefaultAzureCredential, getBearerTokenProvider } from "@azure/identity"; -type GraphApiToken = { - accessToken: string; - [key: string]: unknown; -}; +// export async function getGraphClientUsingMsal( +// tenantId: string, +// clientId: string, +// ): Promise { +// const graphApiToken = await getAccessToken(tenantId, clientId, ["graph"]); -export async function getGraphClient( +// return Client.init({ +// authProvider: (done) => { +// done(null, graphApiToken); +// }, +// }); +// } + +type GraphAuthProvider = ( + done: (error: Error | null, accessToken: string | null) => void +) => void; + +export function getMsalAuthProvider( tenantId: string, clientId: string, -): Promise<{ graphApiToken: GraphApiToken; client: any }> { - const graphApiToken = await acquireResourceToken(tenantId, clientId, "graph") as GraphApiToken; +): GraphAuthProvider { + return (done) => { + void getAccessToken(tenantId, clientId, ["graph"]) + .then((accessToken) => done(null, accessToken)) + .catch((err) => done(err as Error, null)); + }; +} - const client = Client.init({ - authProvider: (done) => { - done(null, graphApiToken.accessToken); - }, +export function getAzureIdentityAuthProvider( + tenantId: string, + clientId: string, +) { + const credential = new DefaultAzureCredential({ + tenantId, + managedIdentityClientId: clientId, }); - return { graphApiToken, client }; + const getBearerToken = getBearerTokenProvider( + credential, + "https://graph.microsoft.com/.default", + ); + + return (done: (error: Error | null, accessToken: string | null) => void) => { + void getBearerToken() + .then((token) => done(null, token)) + .catch((err) => done(err as Error, null)); + }; } diff --git a/src/graph/index.ts b/src/graph/index.ts index 728e430..5040fb2 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -3,3 +3,22 @@ export * from "./auth.ts"; export * from "./app.ts"; export * from "./sp.ts"; + +import { loadAuthConfig, loadConfig } from "../index.ts"; +import { Client } from "@microsoft/microsoft-graph-client"; + +import { getMsalAuthProvider, getAzureIdentityAuthProvider } from "./auth.ts"; + +export async function getGraphClient(): Promise { + const config = await loadConfig(); + + const authConfig = await loadAuthConfig("public-config"); + const authProvider = + config.authMode === "azure-identity" + ? getAzureIdentityAuthProvider(authConfig.tenantId, authConfig.clientId) + : getMsalAuthProvider(authConfig.tenantId, authConfig.clientId); + + return Client.init({ + authProvider: authProvider, + }); +} diff --git a/src/index.ts b/src/index.ts index b06e3e9..6703191 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,14 +2,10 @@ import { validate as validateUuid } from "uuid"; import { getConfig } from "@slawek/sk-tools"; +import type { AuthConfig, Config } from "./types.ts"; -type Config = { - tenantId: string; - clientId: string; -}; - -export async function loadConfig(configName: string): Promise { - if (typeof configName !== "string" || configName.trim() === "") { +export async function loadAuthConfig(configName: string): Promise { + if (configName.trim() === "") { throw new Error( 'Invalid config name. Expected a non-empty string like "public-config" or "confidential-config".', ); @@ -34,3 +30,13 @@ export async function loadConfig(configName: string): Promise { clientId, }; } + +export async function loadConfig(): Promise { + + const json = (await getConfig("sk-az-tools", "config")) as Record; + + return { + activeAccountUpn: typeof json.activeAccountUpn === "string" ? json.activeAccountUpn : undefined, + authMode: typeof json.authMode === "string" ? json.authMode : "msal" + }; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..ec75203 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT + +export type AuthConfig = { + tenantId: string; + clientId: string; +}; + +export type Config = { + activeAccountUpn: string | undefined; + authMode: string; +};