From b678dd5acedea646b9a4e7e8c44f1464b8f3c476 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Wed, 11 Mar 2026 10:41:42 +0100 Subject: [PATCH] Authentication refactoring. --- docs/Commands.md | 6 +-- scripts/sample-az-graph-devops.sh | 29 +++++++++++++ src/azure/index.ts | 51 ++++++++++++----------- src/azure/pca-auth.ts | 38 ++++++++++------- src/cli.ts | 68 ++++++++++++++++++------------- src/cli/commands/auth.ts | 1 - src/cli/commands/get-token.ts | 49 +++++----------------- src/cli/commands/login.ts | 24 +++++++---- src/cli/commands/logout.ts | 25 +++++++++++- src/cli/commands/rest.ts | 39 ++++++++++++------ src/cli/commands/test-command.ts | 6 +++ src/devops/index.ts | 30 ++++---------- 12 files changed, 214 insertions(+), 152 deletions(-) create mode 100755 scripts/sample-az-graph-devops.sh delete mode 100644 src/cli/commands/auth.ts create mode 100644 src/cli/commands/test-command.ts diff --git a/docs/Commands.md b/docs/Commands.md index 502632e..70afab7 100644 --- a/docs/Commands.md +++ b/docs/Commands.md @@ -26,14 +26,14 @@ Note: `rest --header` is a command-specific HTTP header option and is unrelated **Command name:** `login` -**Usage:** `sk-az-tools login [--resources ] [--use-device-code] [--no-browser] [--browser ] [--browser-profile ] [global options]` +**Usage:** `sk-az-tools login [resource...] [--use-device-code] [--no-browser] [--browser-name ] [--browser-profile ] [global options]` **Options:** -- `--resources` - Comma-separated resources to authenticate. Allowed values: `graph`, `devops`, `arm`. Default is all three. +- `[resource...]` - One or more resources to authenticate. Allowed values: `graph`, `devops`, `azurerm`. Default is `azurerm`. - `--use-device-code` - Use device code flow instead of browser-based interactive flow. - `--no-browser` - Do not launch browser automatically. Print the sign-in URL to stderr. -- `--browser` - Browser keyword used for interactive sign-in. Allowed values: `brave`, `browser`, `browserPrivate`, `chrome`, `edge`, `firefox`. +- `--browser-name` - Browser keyword used for interactive sign-in. Allowed values: `brave`, `browser`, `browserPrivate`, `chrome`, `edge`, `firefox`. - `--browser-profile` - Chromium profile name (for example: `Default`, `Profile 1`). **Description:** The `login` command authenticates user sign-in for selected resource audiences and caches tokens for subsequent commands. diff --git a/scripts/sample-az-graph-devops.sh b/scripts/sample-az-graph-devops.sh new file mode 100755 index 0000000..1e78fd6 --- /dev/null +++ b/scripts/sample-az-graph-devops.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +# Hardcode variables. +SUBSCRIPTION_ID="c885a276-c882-483f-b216-42f73715161d" + +ACCESS_TOKEN=$(sk-az-tools get-token graph) + + +# List Azure resource groups via Azure Resource Manager API +echo "Azure Resource Groups in subscription '$SUBSCRIPTION_ID':" +curl -sSL -H "Authorization: Bearer $ACCESS_TOKEN" \ + "https://management.azure.com/subscriptions/$SUBSCRIPTION_ID/resourcegroups?api-version=2021-04-01" | + jq '.value[] | {id, name, location}' + +echo "---" + +# Get current user ('me') via Microsoft Graph +echo "Current User (me):" +curl -sSL -H "Authorization: Bearer $ACCESS_TOKEN" \ + "https://graph.microsoft.com/v1.0/me" | + jq '{id, displayName, userPrincipalName}' + +echo "---" + +# List Azure DevOps projects in the given org +echo "Azure DevOps Projects in org 'skoszewski':" +curl -sSL -H "Authorization: Bearer $ACCESS_TOKEN" \ + "https://dev.azure.com/skoszewski/_apis/projects?api-version=7.1" | + jq '.value[] | {id, name, state}' diff --git a/src/azure/index.ts b/src/azure/index.ts index f404b1c..594b94e 100644 --- a/src/azure/index.ts +++ b/src/azure/index.ts @@ -8,7 +8,10 @@ import { getTokenUsingMsal } from "./pca-auth.ts"; import { getTokenUsingAzureIdentity } from "./client-auth.ts"; -import { loadConfig } from "../index.ts"; +import { loadAuthConfig, loadConfig } from "../index.ts"; +import { SkAzureCredential } from "./sk-credential.ts"; +import { DefaultAzureCredential } from "@azure/identity"; +import type { TokenCredential } from "@azure/core-auth"; // Reexporting functions and types from submodules export { @@ -24,21 +27,42 @@ 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", + azurerm: "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"]; +export const DEFAULT_RESOURCES: ResourceName[] = ["graph", "devops", "azurerm"]; // 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]); } +export function supportedResourceNames(): ResourceName[] { + return Object.keys(RESOURCE_SCOPE_BY_NAME) as ResourceName[]; +} + // Generic utility functions export type AuthMode = "azure-identity" | "msal"; +export async function getTokenCredential( + tenantId?: string, + clientId?: string, +): Promise { + const config = await loadConfig(); + + if (config.authMode === "azure-identity") { + return new DefaultAzureCredential(); + } + + const authConfig = await loadAuthConfig("public-config"); + return new SkAzureCredential( + tenantId || authConfig.tenantId, + clientId || authConfig.clientId, + ); +} + export async function getAccessToken( tenantId: string, clientId: string, @@ -55,24 +79,3 @@ export async function getAccessToken( 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 a978347..f8f65f0 100644 --- a/src/azure/pca-auth.ts +++ b/src/azure/pca-auth.ts @@ -22,14 +22,14 @@ const LOGIN_REQUIRED_MESSAGE = "Login required. Run: sk-az-tools login"; const BROWSER_KEYWORDS = Object.keys(apps).sort(); const OPEN_APPS = apps as Record; const CHROMIUM_BROWSERS = new Set(["edge", "chrome", "brave"]); -const CONFIG_FILE_NAME = "config"; +const SESSION_STATE_NAME = "session-state"; type SessionState = { activeAccountUpn: string | null; }; async function readSessionState(): Promise { - const parsed = (await getConfig("sk-az-tools", CONFIG_FILE_NAME)) as { activeAccountUpn?: unknown }; + const parsed = (await getConfig("sk-az-tools", SESSION_STATE_NAME)) as { activeAccountUpn?: unknown }; return { activeAccountUpn: typeof parsed?.activeAccountUpn === "string" @@ -39,14 +39,14 @@ async function readSessionState(): Promise { } async function writeSessionState(state: SessionState): Promise { - const sessionPath = path.join(getConfigDir("sk-az-tools"), `${CONFIG_FILE_NAME}.json`); + const sessionPath = path.join(getConfigDir("sk-az-tools"), `${SESSION_STATE_NAME}.json`); await mkdir(path.dirname(sessionPath), { recursive: true }); await writeFile(sessionPath, JSON.stringify(state, null, 2), "utf8"); } async function clearSessionState(): Promise { try { - const sessionPath = path.join(getConfigDir("sk-az-tools"), `${CONFIG_FILE_NAME}.json`); + const sessionPath = path.join(getConfigDir("sk-az-tools"), `${SESSION_STATE_NAME}.json`); await unlink(sessionPath); } catch (err) { if ((err as { code?: string } | null)?.code !== "ENOENT") { @@ -104,19 +104,19 @@ function getBrowserOpenOptions(browser?: string, browserProfile?: string): Param const browserKeyword = getBrowserKeyword(browser); if (!CHROMIUM_BROWSERS.has(browserKeyword)) { throw new Error( - "--browser-profile is supported only with --browser edge|chrome|brave", + "--browser-profile is supported only with --browser-name edge|chrome|brave", ); } if (!browserName) { - throw new Error("--browser-profile requires --browser"); + throw new Error("--browser-profile requires --browser-name"); } return { wait: false, app: { name: browserName, - arguments: [`--profile-directory=${browserProfile.trim()}`], + arguments: [`--profile-directory=${browserProfile.trim()}`], }, }; } @@ -130,19 +130,18 @@ function validateBrowserOptions(browser?: string, browserProfile?: string): void const browserKeyword = getBrowserKeyword(browser); if (!CHROMIUM_BROWSERS.has(browserKeyword)) { throw new Error( - "--browser-profile is supported only with --browser edge|chrome|brave", + "--browser-profile is supported only with --browser-name edge|chrome|brave", ); } } } -export function parseResources(resourcesCsv?: string): ResourceName[] { - if (!resourcesCsv || resourcesCsv.trim() === "") { +export function parseResources(resourcesInput?: string[]): ResourceName[] { + if (!resourcesInput || resourcesInput.length === 0) { return [...DEFAULT_RESOURCES]; } - const resources = resourcesCsv - .split(",") + const resources = resourcesInput .map((item) => item.trim().toLowerCase()) .filter(Boolean); @@ -317,7 +316,7 @@ export async function loginDeviceCode( export async function login( tenantId: string, clientId: string, - resourcesCsv?: string, + resourcesInput?: string[], useDeviceCode = false, noBrowser = false, browser?: string, @@ -332,8 +331,8 @@ export async function login( if (!clientId) throw new Error("clientId is required"); validateBrowserOptions(browser, browserProfile); - const resources = parseResources(resourcesCsv); - const scopes = translateResourceNamesToScopes(resources); + const resources = parseResources(resourcesInput); + const scopes = translateResourceNamesToScopes(resources) as string[]; const pca = await createPca(tenantId, clientId); const session = await readSessionState(); const preferredAccount = session.activeAccountUpn @@ -344,6 +343,10 @@ export async function login( let selectedAccount: AccountInfo | null = preferredAccount; let token = await acquireTokenWithCache(pca, scopes, selectedAccount); + if (token?.account) { + selectedAccount = token.account; + } + if (!token) { if (useDeviceCode) { token = await pca.acquireTokenByDeviceCode({ @@ -378,6 +381,11 @@ export async function login( }); } + if (!selectedAccount) { + const accounts = await pca.getTokenCache().getAllAccounts(); + selectedAccount = accounts[0] ?? null; + } + const activeAccountUpn = selectedAccount?.username ?? null; if (activeAccountUpn) { await writeSessionState({ activeAccountUpn }); diff --git a/src/cli.ts b/src/cli.ts index 5d3045d..44dca0b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,8 +1,9 @@ #!/usr/bin/env node // SPDX-License-Identifier: MIT -import { Command, Option } from "commander"; +import { Argument, Command, Option } from "commander"; import { renderCliOutput } from "@slawek/sk-tools"; +import { supportedResourceNames, ResourceName } from "./azure/index.ts"; // Commands import { runGetTokenCommand } from "./cli/commands/get-token.ts"; @@ -13,6 +14,7 @@ import { runListResourcePermissionsCommand } from "./cli/commands/list-resource- import { runLoginCommand } from "./cli/commands/login.ts"; import { runLogoutCommand } from "./cli/commands/logout.ts"; import { runRestCommand } from "./cli/commands/rest.ts"; +import { runTestCommand } from "./cli/commands/test-command.ts"; import pkg from "../package.json" with { type: "json" }; const { version: packageVersion } = pkg; @@ -33,33 +35,28 @@ async function main(): Promise { skAzTools .command("login") .description("Authenticate selected resources") - .option("--resources ", "Comma-separated resources: graph,devops,arm") + .addArgument( + new Argument("[resource...]", "Resources: graph|devops|azurerm") + .choices(supportedResourceNames()) + .default(["azurerm"]), + ) .option("--use-device-code", "Use device code flow") .option("--no-browser", "Do not launch browser") - .option("--browser ", "Browser keyword: brave|browser|browserPrivate|chrome|edge|firefox") + .option("--browser-name ", "Browser keyword: brave|browser|browserPrivate|chrome|edge|firefox") .option("--browser-profile ", "Chromium profile name") - .action(async (options) => { - const output = await runLoginCommand(options); - renderCliOutput(output, skAzTools.opts().output, skAzTools.opts().query, skAzTools.opts().columns); - }); + .action(runLoginCommand); skAzTools .command("logout") .description("Sign out and clear login state") .option("--all", "Clear login state and remove all cached accounts") - .action(async (options) => { - const output = await runLogoutCommand(options); - renderCliOutput(output, skAzTools.opts().output, skAzTools.opts().query, skAzTools.opts().columns); - }); + .action(runLogoutCommand); skAzTools .command("get-token") - .description("Get access token (azurerm|devops)") - .addOption(new Option("-t, --type ", "Token type").choices(["azurerm", "devops"])) - .action(async (options) => { - const output = await runGetTokenCommand(options); - renderCliOutput(output, skAzTools.opts().output, skAzTools.opts().query, skAzTools.opts().columns); - }); + .description("Get an access token for a resource or resources.") + .addArgument(new Argument("", "Token type.").choices(supportedResourceNames())) + .action(runGetTokenCommand); skAzTools .command("rest") @@ -71,10 +68,14 @@ async function main(): Promise { .addHelpText("after", ` Authorization is added automatically for: management.azure.com Uses azurerm token - dev.azure.com Uses devops token`) - .action(async (url, options) => { + dev.azure.com Uses devops token + graph.microsoft.com Uses graph token + cognitiveservices.azure.com Uses openai token + *.openai.azure.com Uses openai token`) + .action(async (url, options, command) => { const output = await runRestCommand(url, options); - renderCliOutput(output, skAzTools.opts().output, skAzTools.opts().query, skAzTools.opts().columns); + const allOptions = command.optsWithGlobals(); + renderCliOutput(output, allOptions.output, allOptions.query, allOptions.columns); }); skAzTools @@ -83,9 +84,10 @@ Authorization is added automatically for: .option("-n, --display-name ", "Get app by display name") .option("-i, --app-id ", "Get app by id") .option("-f, --filter ", "Filter display name glob") - .action(async (options) => { + .action(async (options, command) => { const output = await runListAppsCommand(options); - renderCliOutput(output, skAzTools.opts().output, skAzTools.opts().query, skAzTools.opts().columns); + const allOptions = command.optsWithGlobals(); + renderCliOutput(output, allOptions.output, allOptions.query, allOptions.columns); }); skAzTools @@ -95,18 +97,20 @@ Authorization is added automatically for: .option("-r, --resolve", "Resolve permission GUIDs to human-readable values") .option("-s, --short", "Makes output more compact") .option("-f, --filter ", "Filter by permission name glob") - .action(async (options) => { + .action(async (options, command) => { const output = await runListAppPermissionsCommand(options); - renderCliOutput(output, skAzTools.opts().output, skAzTools.opts().query, skAzTools.opts().columns); + const allOptions = command.optsWithGlobals(); + renderCliOutput(output, allOptions.output, allOptions.query, allOptions.columns); }); skAzTools .command("list-app-grants") .description("List OAuth2 grants for an app") .option("-i, --app-id ", "Application (client) ID") - .action(async (options) => { + .action(async (options, command) => { const output = await runListAppGrantsCommand(options); - renderCliOutput(output, skAzTools.opts().output, skAzTools.opts().query, skAzTools.opts().columns); + const allOptions = command.optsWithGlobals(); + renderCliOutput(output, allOptions.output, allOptions.query, allOptions.columns); }); skAzTools @@ -115,10 +119,18 @@ Authorization is added automatically for: .option("-i, --app-id ", "Resource app ID") .option("-n, --display-name ", "Resource app display name") .option("-f, --filter ", "Filter by permission name glob") - .action(async (options) => { + .action(async (options, command) => { const output = await runListResourcePermissionsCommand(options); - renderCliOutput(output, skAzTools.opts().output, skAzTools.opts().query, skAzTools.opts().columns); + const allOptions = command.optsWithGlobals(); + renderCliOutput(output, allOptions.output, allOptions.query, allOptions.columns); }); + + // Hidden test command for development purposes + skAzTools + .command("test", { hidden: true }) + .description("Test command for development") + .action(runTestCommand); + await skAzTools.parseAsync(); } diff --git a/src/cli/commands/auth.ts b/src/cli/commands/auth.ts deleted file mode 100644 index 8096cb4..0000000 --- a/src/cli/commands/auth.ts +++ /dev/null @@ -1 +0,0 @@ -// SPDX-License-Identifier: MIT diff --git a/src/cli/commands/get-token.ts b/src/cli/commands/get-token.ts index b851cb1..bc9dd8f 100644 --- a/src/cli/commands/get-token.ts +++ b/src/cli/commands/get-token.ts @@ -1,48 +1,21 @@ // SPDX-License-Identifier: MIT -import { getAccessToken } from "../../azure/index.ts"; -import { getDevOpsApiToken } from "../../devops/index.ts"; -import { loadAuthConfig } from "../../index.ts"; - -type GetTokenOptions = { - type?: string; -}; +import { RESOURCE_SCOPE_BY_NAME, ResourceName, supportedResourceNames, getTokenCredential } from "../../azure/index.ts"; export async function runGetTokenCommand( - options: GetTokenOptions, -): Promise { - const tokenType = (options.type ?? "").toString().trim().toLowerCase(); - if (!tokenType) { - throw new Error( - "--type is required for get-token (allowed: azurerm, devops)", - ); + type: ResourceName, +): Promise { + if (!type || !supportedResourceNames().includes(type)) { + throw new Error(`Token type is required for get-token (allowed: ${supportedResourceNames().join(", ")})`); } - const config = await loadAuthConfig("public-config"); + const credential = await getTokenCredential(); - if (tokenType === "azurerm") { - const accessToken = await getAccessToken(config.tenantId, config.clientId, ["arm"]); - - if (!accessToken) { - throw new Error("Failed to obtain AzureRM token"); - } - - return { - tokenType, - accessToken, - }; + const accessToken = await credential.getToken(RESOURCE_SCOPE_BY_NAME[type]); + if (!accessToken) { + throw new Error("Failed to obtain access token."); } - if (tokenType === "devops") { - const accessToken = await getDevOpsApiToken( - config.tenantId, - config.clientId, - ); - return { - tokenType, - accessToken, - }; - } - - throw new Error(`Invalid --type '${options.type}'. Allowed: azurerm, devops`); + // Output only the token string for easy consumption in scripts + console.log(accessToken.token); } diff --git a/src/cli/commands/login.ts b/src/cli/commands/login.ts index 298924d..b7e9390 100644 --- a/src/cli/commands/login.ts +++ b/src/cli/commands/login.ts @@ -1,25 +1,35 @@ // SPDX-License-Identifier: MIT import { login } from "../../azure/index.ts"; +import type { ResourceName } from "../../azure/index.ts"; import { loadAuthConfig } from "../../index.ts"; type LoginOptions = { - resources?: string; useDeviceCode?: boolean; noBrowser?: boolean; - browser?: string; + browserName?: string; browserProfile?: string; }; -export async function runLoginCommand(options: LoginOptions): Promise { +type LoginResult = { + accountUpn: string | null; + resources: Array<{ resource: string; expiresOn: string | null }>; + flow: "device-code" | "interactive"; + browserLaunchAttempted: boolean; +}; + +export async function runLoginCommand(resources: ResourceName[], options: LoginOptions): Promise { const config = await loadAuthConfig("public-config"); - return login( + + const result = await login( config.tenantId, config.clientId, - options.resources, + resources, Boolean(options.useDeviceCode), Boolean(options.noBrowser), - options.browser, + options.browserName, options.browserProfile, - ); + ) as LoginResult; + + console.log(`Logged in as ${result.accountUpn ?? ""} using ${result.flow} flow for resources: ${resources.join(",")}`); } diff --git a/src/cli/commands/logout.ts b/src/cli/commands/logout.ts index d69765b..5669dac 100644 --- a/src/cli/commands/logout.ts +++ b/src/cli/commands/logout.ts @@ -7,7 +7,28 @@ type LogoutOptions = { all?: boolean; }; -export async function runLogoutCommand(options: LogoutOptions): Promise { +type LogoutResult = { + clearedAll: boolean; + signedOut: string[]; +}; + +export async function runLogoutCommand(options: LogoutOptions): Promise { const config = await loadAuthConfig("public-config"); - return logout(config.tenantId, config.clientId, Boolean(options.all)); + const result = await logout(config.tenantId, config.clientId, Boolean(options.all)) as LogoutResult; + + if (result.signedOut.length === 0) { + console.log( + result.clearedAll + ? "Cleared all cached accounts." + : "No active account to sign out.", + ); + return; + } + + if (result.clearedAll) { + console.log(`Cleared all cached accounts: ${result.signedOut.join(", ")}`); + return; + } + + console.log(`Signed out: ${result.signedOut.join(", ")}`); } diff --git a/src/cli/commands/rest.ts b/src/cli/commands/rest.ts index 8e488e4..7aa2b62 100644 --- a/src/cli/commands/rest.ts +++ b/src/cli/commands/rest.ts @@ -1,8 +1,6 @@ // SPDX-License-Identifier: MIT -import { getAccessToken } from "../../azure/index.ts"; -import { getDevOpsApiToken } from "../../devops/index.ts"; -import { loadAuthConfig } from "../../index.ts"; +import { RESOURCE_SCOPE_BY_NAME, ResourceName, getTokenCredential } from "../../azure/index.ts"; function parseHeaderLine( header?: string, @@ -35,24 +33,39 @@ function hasAuthorizationHeader(headers: Headers): boolean { return false; } +function resolveResourceNameForHost(host: string): ResourceName | null { + switch (host) { + case "management.azure.com": + return "azurerm"; + case "dev.azure.com": + return "devops"; + case "graph.microsoft.com": + return "graph"; + case "cognitiveservices.azure.com": + return "openai"; + default: + if (host.endsWith(".openai.azure.com")) { + return "openai"; + } + return null; + } +} + async function getAutoAuthorizationHeader(url: URL): Promise { const host = url.hostname.toLowerCase(); - if (host !== "management.azure.com" && host !== "dev.azure.com") { + const resourceName = resolveResourceNameForHost(host); + if (!resourceName) { return null; } - const config = await loadAuthConfig("public-config"); + const credential = await getTokenCredential(); - if (host === "management.azure.com") { - const accessToken = await getAccessToken(config.tenantId, config.clientId, ["arm"]); - if (!accessToken) { - throw new Error("Failed to obtain AzureRM token"); - } - return `Bearer ${accessToken}`; + const accessToken = await credential.getToken(RESOURCE_SCOPE_BY_NAME[resourceName]); + if (!accessToken?.token) { + throw new Error(`Failed to obtain ${resourceName} token`); } - const accessToken = await getDevOpsApiToken(config.tenantId, config.clientId); - return `Bearer ${accessToken}`; + return `Bearer ${accessToken.token}`; } type httpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; diff --git a/src/cli/commands/test-command.ts b/src/cli/commands/test-command.ts new file mode 100644 index 0000000..9640d8b --- /dev/null +++ b/src/cli/commands/test-command.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT + +// Hidden test command for development purposes +export async function runTestCommand(): Promise { + console.log("Test command executed."); +} diff --git a/src/devops/index.ts b/src/devops/index.ts index 4006367..689043c 100644 --- a/src/devops/index.ts +++ b/src/devops/index.ts @@ -4,35 +4,23 @@ * A DevOps helpers module. */ -import { loginInteractive } from "../azure/index.ts"; +import { RESOURCE_SCOPE_BY_NAME, getTokenCredential } from "../azure/index.ts"; import * as azdev from "azure-devops-node-api"; -const AZURE_DEVOPS_SCOPES = ["https://app.vssps.visualstudio.com/.default"]; - -type LoginInteractiveResult = { - accessToken?: string; +export type DevOpsClients = { + coreClient: Awaited>; + gitClient: Awaited>; }; -export async function getDevOpsApiToken(tenantId: string, clientId: string): Promise { - const result = await loginInteractive( - tenantId, - clientId, - AZURE_DEVOPS_SCOPES, - ) as LoginInteractiveResult; +export async function getDevOpsClients(orgUrl: string, tenantId?: string, clientId?: string): Promise { + const credential = await getTokenCredential(tenantId, clientId); - const accessToken = result?.accessToken; - - if (!accessToken) { + const accessToken = await credential.getToken(RESOURCE_SCOPE_BY_NAME.devops); + if (!accessToken?.token) { throw new Error("Failed to obtain Azure DevOps API token"); } - return accessToken; -} - -export async function getDevOpsClients(orgUrl: string, tenantId: string, clientId: string): Promise<{ coreClient: unknown; gitClient: unknown }> { - const accessToken = await getDevOpsApiToken(tenantId, clientId); - - const authHandler = azdev.getBearerHandler(accessToken); + const authHandler = azdev.getBearerHandler(accessToken.token); const connection = new azdev.WebApi(orgUrl, authHandler); const coreClient = await connection.getCoreApi();