Skip to content

Commit

Permalink
[identity] Use expires_on if az cli credential provides it (#28333)
Browse files Browse the repository at this point in the history
### Packages impacted by this PR

@azure/identity

### Issues associated with this PR

Resolves #27648

### Describe the problem that is addressed by this PR

In version 2.54.0 the Azure CLI started including an `expires_on` field
containing a POSIX timestamp in seconds in addition to the existing
`expiresOn` field denoting the expiry in RFC3339 format.

We want to migrate to `expires_on` when the underlying az cli supports
it and fallback to the existing `expiresOn` otherwise.


### Provide a list of related PRs _(if any)_

Azure/azure-sdk-for-net#41366
Azure/azure-sdk-for-cpp#5075
  • Loading branch information
maorleger authored Jan 24, 2024
1 parent 0d606f3 commit e7afb55
Show file tree
Hide file tree
Showing 6 changed files with 529 additions and 387 deletions.
770 changes: 397 additions & 373 deletions common/config/rush/pnpm-lock.yaml

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion sdk/identity/identity/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
# Release History

## 4.0.2 (Unreleased)
## 4.1.0-beta.1 (Unreleased)

### Features Added

- `AzureCliCredential`: Added support for the new response field which represents token expiration timestamp as time zone agnostic value. ([#28333](https://github.com/Azure/azure-sdk-for-js/pull/28333))

### Breaking Changes

### Bugs Fixed
Expand Down
2 changes: 1 addition & 1 deletion sdk/identity/identity/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@azure/identity",
"sdk-type": "client",
"version": "4.0.2",
"version": "4.1.0-beta.1",
"description": "Provides credential implementations for Azure SDK libraries that can authenticate with Microsoft Entra ID",
"main": "dist/index.js",
"module": "dist-esm/src/index.js",
Expand Down
2 changes: 1 addition & 1 deletion sdk/identity/identity/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
/**
* Current version of the `@azure/identity` package.
*/
export const SDK_VERSION = `4.0.2`;
export const SDK_VERSION = `4.1.0-beta.1`;

/**
* The default client ID for authentication
Expand Down
59 changes: 48 additions & 11 deletions sdk/identity/identity/src/credentials/azureCliCredential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@
// Licensed under the MIT license.

import { AccessToken, GetTokenOptions, TokenCredential } from "@azure/core-auth";
import { credentialLogger, formatError, formatSuccess } from "../util/logging";
import { ensureValidScopeForDevTimeCreds, getScopeResource } from "../util/scopeUtils";
import { AzureCliCredentialOptions } from "./azureCliCredentialOptions";
import { CredentialUnavailableError } from "../errors";
import child_process from "child_process";
import {
checkTenantId,
processMultiTenantRequest,
resolveAdditionallyAllowedTenantIds,
} from "../util/tenantIdUtils";
import { credentialLogger, formatError, formatSuccess } from "../util/logging";
import { ensureValidScopeForDevTimeCreds, getScopeResource } from "../util/scopeUtils";

import { AzureCliCredentialOptions } from "./azureCliCredentialOptions";
import { CredentialUnavailableError } from "../errors";
import child_process from "child_process";
import { tracingClient } from "../util/tracing";

/**
Expand Down Expand Up @@ -158,13 +159,9 @@ export class AzureCliCredential implements TokenCredential {
}
try {
const responseData = obj.stdout;
const response: { accessToken: string; expiresOn: string } = JSON.parse(responseData);
const response: AccessToken = this.parseRawResponse(responseData);
logger.getToken.info(formatSuccess(scopes));
const returnValue = {
token: response.accessToken,
expiresOnTimestamp: new Date(response.expiresOn).getTime(),
};
return returnValue;
return response;
} catch (e: any) {
if (obj.stderr) {
throw new CredentialUnavailableError(obj.stderr);
Expand All @@ -183,4 +180,44 @@ export class AzureCliCredential implements TokenCredential {
}
});
}

/**
* Parses the raw JSON response from the Azure CLI into a usable AccessToken object
*
* @param rawResponse - The raw JSON response from the Azure CLI
* @returns An access token with the expiry time parsed from the raw response
*
* The expiryTime of the credential's access token, in milliseconds, is calculated as follows:
*
* When available, expires_on (introduced in Azure CLI v2.54.0) will be preferred. Otherwise falls back to expiresOn.
*/
private parseRawResponse(rawResponse: string): AccessToken {
const response: any = JSON.parse(rawResponse);
const token = response.accessToken;
// if available, expires_on will be a number representing seconds since epoch.
// ensure it's a number or NaN
let expiresOnTimestamp = Number.parseInt(response.expires_on, 10) * 1000;
if (!isNaN(expiresOnTimestamp)) {
logger.getToken.info("expires_on is available and is valid, using it");
return {
token,
expiresOnTimestamp,
};
}

// fallback to the older expiresOn - an RFC3339 date string
expiresOnTimestamp = new Date(response.expiresOn).getTime();

// ensure expiresOn is well-formatted
if (isNaN(expiresOnTimestamp)) {
throw new CredentialUnavailableError(
`Unexpected response from Azure CLI when getting token. Expected "expiresOn" to be a RFC3339 date string. Got: "${response.expiresOn}"`,
);
}

return {
token,
expiresOnTimestamp,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT license.

import Sinon, { createSandbox } from "sinon";

import { AzureCliCredential } from "../../../src/credentials/azureCliCredential";
import { GetTokenOptions } from "@azure/core-auth";
import { assert } from "@azure/test-utils";
Expand Down Expand Up @@ -299,4 +300,82 @@ az login --scope https://test.windows.net/.default`;
const actualToken = await credential.getToken("https://service/.default");
assert.equal(actualToken!.token, "token");
});

describe("expiresOnTimestamp", function () {
const testData = {
expires_on: {
inputValue: 1705963934,
expected: 1705963934000,
},
expiresOn: {
inputValue: "1999-01-22 14:52:14.000000",
expected: new Date("1999-01-22 14:52:14.000000").getTime(),
},
};

it("uses expires_on when provided", async function () {
stdout = `
{
"accessToken": "token",
"expires_on": ${testData.expires_on.inputValue}
}`;
stderr = "";
const credential = new AzureCliCredential();
const actualToken = await credential.getToken("https://service/.default");
assert.equal(actualToken.expiresOnTimestamp, testData.expires_on.expected);
});

it("uses expiresOn when expires_on is empty", async function () {
stdout = `
{
"accessToken": "token",
"expiresOn": "${testData.expiresOn.inputValue}"
}`;
stderr = "";
const credential = new AzureCliCredential();
const actualToken = await credential.getToken("https://service/.default");
assert.equal(actualToken.expiresOnTimestamp, testData.expiresOn.expected);
});

it("prefers expires_on when both expires_on and expiresOn are provided", async function () {
stdout = `
{
"accessToken": "token",
"expiresOn": "${testData.expiresOn.inputValue}",
"expires_on": ${testData.expires_on.inputValue}
}`;
stderr = "";
const credential = new AzureCliCredential();
const actualToken = await credential.getToken("https://service/.default");
assert.equal(actualToken.expiresOnTimestamp, testData.expires_on.expected);
});

it("uses expiresOn when expires_on is invalid", async function () {
stdout = `
{
"accessToken": "token",
"expiresOn": "${testData.expiresOn.inputValue}",
"expires_on": "not-a-number"
}`;
stderr = "";
const credential = new AzureCliCredential();
const actualToken = await credential.getToken("https://service/.default");
assert.equal(actualToken.expiresOnTimestamp, testData.expiresOn.expected);
});

it("throws when both are invalid", async function () {
stdout = `
{
"accessToken": "token",
"expiresOn": "not-a-date",
"expires_on": "not-a-number"
}`;
stderr = "";
const credential = new AzureCliCredential();
await assert.isRejected(
credential.getToken("https://service/.default"),
/Expected "expiresOn" to be a RFC3339 date string. Got: "not-a-date"$/,
);
});
});
});

0 comments on commit e7afb55

Please sign in to comment.