azureCliCredentials.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. // Copyright (c) Microsoft Corporation. All rights reserved.
  2. // Licensed under the MIT License. See License.txt in the project root for license information.
  3. import { Constants as MSRestConstants, WebResource } from "@azure/ms-rest-js";
  4. import { TokenClientCredentials, TokenResponse } from "./tokenClientCredentials";
  5. import { LinkedSubscription } from "../subscriptionManagement/subscriptionUtils";
  6. import { execAz } from "../login";
  7. interface ParsedToken {
  8. /**
  9. * The token audience or the resource.
  10. */
  11. aud: string;
  12. [prop: string]: any;
  13. }
  14. /**
  15. * Describes the access token retrieved from Azure CLI.
  16. */
  17. export interface CliAccessToken {
  18. /**
  19. * The access token for the resource
  20. */
  21. accessToken: string;
  22. /**
  23. * Time when the access token expires.
  24. */
  25. expiresOn: Date;
  26. /**
  27. * SubscriptionId associated with the token.
  28. */
  29. subscription: string;
  30. /**
  31. * tenantId associated with the token.
  32. */
  33. tenant: string;
  34. /**
  35. * The token type. example: "Bearer".
  36. */
  37. tokenType: string;
  38. }
  39. /**
  40. * Describes the options that can be provided while listing all the subscriptions/accounts via
  41. * Azure CLI.
  42. */
  43. export interface ListAllSubscriptionOptions {
  44. /**
  45. * List all subscriptions, rather just 'Enabled' ones.
  46. */
  47. all?: boolean;
  48. /**
  49. * Retrieve up-to-date subscriptions from server.
  50. */
  51. refresh?: boolean;
  52. }
  53. export interface AccessTokenOptions {
  54. /**
  55. * The subscription id or name for which the access token is required.
  56. */
  57. subscriptionIdOrName?: string;
  58. /**
  59. * Azure resource endpoints.
  60. * - Defaults to Azure Resource Manager from environment: AzureCloud. "https://management.azure.com"
  61. * - For Azure KeyVault: "https://vault.azure.net"
  62. * - For Azure Batch: "https://batch.core.windows.net"
  63. * - For Azure Active Directory Graph: "https://graph.windows.net"
  64. *
  65. * To get the resource for other clouds:
  66. * - `az cloud list`
  67. */
  68. resource?: string;
  69. }
  70. /**
  71. * Describes the credentials by retrieving token via Azure CLI.
  72. */
  73. export class AzureCliCredentials implements TokenClientCredentials {
  74. /**
  75. * Provides information about the default/current subscription for Azure CLI.
  76. */
  77. subscriptionInfo: LinkedSubscription;
  78. /**
  79. * Provides information about the access token for the corresponding subscription for Azure CLI.
  80. */
  81. tokenInfo: CliAccessToken;
  82. /**
  83. * Azure resource endpoints.
  84. * - Defaults to Azure Resource Manager from environment: AzureCloud. "https://management.azure.com"
  85. * - For Azure KeyVault: "https://vault.azure.net"
  86. * - For Azure Batch: "https://batch.core.windows.net"
  87. * - For Azure Active Directory Graph: "https://graph.windows.net"
  88. *
  89. * To get the resource for other clouds:
  90. * - `az cloud list`
  91. */
  92. // tslint:disable-next-line: no-inferrable-types
  93. resource: string = "https://management.azure.com";
  94. /**
  95. * The number of seconds within which it is good to renew the token.
  96. * A constant set to 270 seconds (4.5 minutes).
  97. */
  98. private readonly _tokenRenewalMarginInSeconds: number = 270;
  99. constructor(
  100. subscriptionInfo: LinkedSubscription,
  101. tokenInfo: CliAccessToken,
  102. // tslint:disable-next-line: no-inferrable-types
  103. resource: string = "https://management.azure.com") {
  104. this.subscriptionInfo = subscriptionInfo;
  105. this.tokenInfo = tokenInfo;
  106. this.resource = resource;
  107. }
  108. /**
  109. * Tries to get the new token from Azure CLI, if the token has expired or the subscription has
  110. * changed else uses the cached accessToken.
  111. * @return The tokenResponse (tokenType and accessToken are the two important properties).
  112. */
  113. public async getToken(): Promise<TokenResponse> {
  114. if (this._hasTokenExpired() || this._hasSubscriptionChanged() || this._hasResourceChanged()) {
  115. try {
  116. // refresh the access token
  117. this.tokenInfo = await AzureCliCredentials.getAccessToken(
  118. {
  119. subscriptionIdOrName: this.subscriptionInfo.id,
  120. resource: this.resource
  121. }
  122. );
  123. } catch (err) {
  124. throw new Error(
  125. `An error occurred while refreshing the new access ` +
  126. `token:${err.stderr ? err.stderr : err.message}`
  127. );
  128. }
  129. }
  130. const result: TokenResponse = {
  131. accessToken: this.tokenInfo.accessToken,
  132. tokenType: this.tokenInfo.tokenType,
  133. expiresOn: this.tokenInfo.expiresOn,
  134. tenantId: this.tokenInfo.tenant
  135. };
  136. return result;
  137. }
  138. /**
  139. * Signs a request with the Authentication header.
  140. * @param The request to be signed.
  141. */
  142. public async signRequest(webResource: WebResource): Promise<WebResource> {
  143. const tokenResponse = await this.getToken();
  144. webResource.headers.set(
  145. MSRestConstants.HeaderConstants.AUTHORIZATION,
  146. `${tokenResponse.tokenType} ${tokenResponse.accessToken}`
  147. );
  148. return Promise.resolve(webResource);
  149. }
  150. private _hasTokenExpired(): boolean {
  151. let result = true;
  152. const now = Math.floor(Date.now() / 1000);
  153. if (this.tokenInfo.expiresOn &&
  154. this.tokenInfo.expiresOn instanceof Date &&
  155. Math.floor(this.tokenInfo.expiresOn.getTime() / 1000) - now > this._tokenRenewalMarginInSeconds) {
  156. result = false;
  157. }
  158. return result;
  159. }
  160. private _hasSubscriptionChanged(): boolean {
  161. return this.subscriptionInfo.id !== this.tokenInfo.subscription;
  162. }
  163. private _parseToken(): ParsedToken {
  164. try {
  165. const base64Url: string = this.tokenInfo.accessToken.split(".")[1];
  166. const base64: string = decodeURIComponent(
  167. Buffer.from(base64Url, "base64").toString("binary").split("").map((c) => {
  168. return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2);
  169. }).join(""));
  170. return JSON.parse(base64);
  171. } catch (err) {
  172. const msg = `An error occurred while parsing the access token: ${err.stack}`;
  173. throw new Error(msg);
  174. }
  175. }
  176. private _isAzureResourceManagerEndpoint(newResource: string, currentResource: string): boolean {
  177. if (newResource.endsWith("/")) newResource = newResource.slice(0, -1);
  178. if (currentResource.endsWith("/")) currentResource = currentResource.slice(0, -1);
  179. return (newResource === "https://management.core.windows.net" &&
  180. currentResource === "https://management.azure.com") ||
  181. (newResource === "https://management.azure.com" &&
  182. currentResource === "https://management.core.windows.net");
  183. }
  184. private _hasResourceChanged(): boolean {
  185. const parsedToken: ParsedToken = this._parseToken();
  186. // normalize the resource string, since it is possible to
  187. // provide a resource without a trailing slash
  188. const currentResource = parsedToken.aud && parsedToken.aud.endsWith("/")
  189. ? parsedToken.aud.slice(0, -1)
  190. : parsedToken.aud;
  191. const newResource = this.resource.endsWith("/")
  192. ? this.resource.slice(0, -1)
  193. : this.resource;
  194. const result = this._isAzureResourceManagerEndpoint(newResource, currentResource)
  195. ? false
  196. : currentResource !== newResource;
  197. return result;
  198. }
  199. /**
  200. * Gets the access token for the default or specified subscription.
  201. * @param options Optional parameters that can be provided to get the access token.
  202. */
  203. static async getAccessToken(options: AccessTokenOptions = {}): Promise<CliAccessToken> {
  204. try {
  205. let cmd = "account get-access-token";
  206. if (options.subscriptionIdOrName) {
  207. cmd += ` -s "${options.subscriptionIdOrName}"`;
  208. }
  209. if (options.resource) {
  210. cmd += ` --resource ${options.resource}`;
  211. }
  212. const result: any = await execAz(cmd);
  213. result.expiresOn = new Date(result.expiresOn);
  214. return result as CliAccessToken;
  215. } catch (err) {
  216. const message =
  217. `An error occurred while getting credentials from ` +
  218. `Azure CLI: ${err.stack}`;
  219. throw new Error(message);
  220. }
  221. }
  222. /**
  223. * Gets the subscription from Azure CLI.
  224. * @param subscriptionIdOrName - The name or id of the subscription for which the information is
  225. * required.
  226. */
  227. static async getSubscription(subscriptionIdOrName?: string): Promise<LinkedSubscription> {
  228. if (subscriptionIdOrName && (typeof subscriptionIdOrName !== "string" || !subscriptionIdOrName.length)) {
  229. throw new Error("'subscriptionIdOrName' must be a non-empty string.");
  230. }
  231. try {
  232. let cmd = "account show";
  233. if (subscriptionIdOrName) {
  234. cmd += ` -s "${subscriptionIdOrName}"`;
  235. }
  236. const result: LinkedSubscription = await execAz(cmd);
  237. return result;
  238. } catch (err) {
  239. const message =
  240. `An error occurred while getting information about the current subscription from ` +
  241. `Azure CLI: ${err.stack}`;
  242. throw new Error(message);
  243. }
  244. }
  245. /**
  246. * Sets the specified subscription as the default subscription for Azure CLI.
  247. * @param subscriptionIdOrName The name or id of the subsciption that needs to be set as the
  248. * default subscription.
  249. */
  250. static async setDefaultSubscription(subscriptionIdOrName: string): Promise<void> {
  251. try {
  252. await execAz(`account set -s ${subscriptionIdOrName}`);
  253. } catch (err) {
  254. const message =
  255. `An error occurred while setting the current subscription from ` +
  256. `Azure CLI: ${err.stack}`;
  257. throw new Error(message);
  258. }
  259. }
  260. /**
  261. * Returns a list of all the subscriptions from Azure CLI.
  262. * @param options Optional parameters that can be provided while listing all the subcriptions.
  263. */
  264. static async listAllSubscriptions(options: ListAllSubscriptionOptions = {}): Promise<LinkedSubscription[]> {
  265. let subscriptionList: any[] = [];
  266. try {
  267. let cmd = "account list";
  268. if (options.all) {
  269. cmd += " --all";
  270. }
  271. if (options.refresh) {
  272. cmd += "--refresh";
  273. }
  274. subscriptionList = await execAz(cmd);
  275. if (subscriptionList && subscriptionList.length) {
  276. for (const sub of subscriptionList) {
  277. if (sub.cloudName) {
  278. sub.environmentName = sub.cloudName;
  279. delete sub.cloudName;
  280. }
  281. }
  282. }
  283. return subscriptionList;
  284. } catch (err) {
  285. const message =
  286. `An error occurred while getting a list of all the subscription from ` +
  287. `Azure CLI: ${err.stack}`;
  288. throw new Error(message);
  289. }
  290. }
  291. /**
  292. * Provides credentials that can be used by the JS SDK to interact with Azure via azure cli.
  293. * **Pre-requisite**
  294. * - **install azure-cli** . For more information see
  295. * {@link https://docs.microsoft.com/en-us/cli/azure/install-azure-cli?view=azure-cli-latest Install Azure CLI}
  296. * - **login via `az login`**
  297. * @param options - Optional parameters that can be provided while creating AzureCliCredentials.
  298. */
  299. static async create(options: AccessTokenOptions = {}): Promise<AzureCliCredentials> {
  300. const [subscriptinInfo, accessToken] = await Promise.all([
  301. AzureCliCredentials.getSubscription(options.subscriptionIdOrName),
  302. AzureCliCredentials.getAccessToken(options)
  303. ]);
  304. return new AzureCliCredentials(subscriptinInfo, accessToken, options.resource);
  305. }
  306. }