Skip to content

Commit 849472a

Browse files
authored
[core-http] Token refresher fix (Azure#13736)
The current token refreshing mechanism in core-http was wrong. The token was being refreshed before the "refresh time buffer" was reached. I've added tests to verify the behavior (if we change the source back, the tests do fail, as expected). With this PR, here's what we will be doing: 1. If a token is not there or has expired, we ask for a new one (of course). 2. Before a token expires, in a time window, we try to retrieve the token through a refresh promise. 3. As we get new requests to retrieve that token, we re-use the same refresh promise. 4. If the refresh promise fails, next incoming request will re-run the refresh promise only after some specific time. (This is true again, I reverted my changes to this) Please let me know how this looks! Feedback appreciated. Reverted: ~**[Important]** The update after Jeff's approval is to fix the behavior to match the original intention. Only within the refresh window, while the valid token is still returned, the refresh promise should try to refresh the token once every 30 milliseconds (configurable) until the token expires. Once the token expires, the process does a final attempt to update it, then it stops.~ Incidentally fixes this customer issue, in which the token was refreshed more times than expected: Fixes Azure#13369
1 parent 2e3fee5 commit 849472a

File tree

5 files changed

+96
-56
lines changed

5 files changed

+96
-56
lines changed

sdk/core/core-http/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## 1.2.4 (Unreleased)
44

5+
- Updated the `bearerTokenAuthenticationPolicy` to refresh tokens only when they're about to expire and not multiple times before. This fixes the issue: [13369](https://github.com/Azure/azure-sdk-for-js/issues/13369).
56

67
## 1.2.3 (2021-02-04)
78

sdk/core/core-http/karma.conf.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const defaults = {
44

55
process.env.CHROME_BIN = require("puppeteer").executablePath();
66

7-
module.exports = function(config: any) {
7+
module.exports = function (config: any) {
88
config.set({
99
plugins: [
1010
"karma-mocha",

sdk/core/core-http/src/credentials/accessTokenRefresher.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export class AccessTokenRefresher {
2323
public isReady(): boolean {
2424
// We're only ready for a new refresh if the required milliseconds have passed.
2525
return (
26-
!this.lastCalled || Date.now() - this.lastCalled > this.requiredMillisecondsBeforeNewRefresh
26+
!this.lastCalled || Date.now() - this.lastCalled >= this.requiredMillisecondsBeforeNewRefresh
2727
);
2828
}
2929

sdk/core/core-http/src/policies/bearerTokenAuthenticationPolicy.ts

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -87,30 +87,14 @@ export class BearerTokenAuthenticationPolicy extends BaseRequestPolicy {
8787
return this._nextPolicy.sendRequest(webResource);
8888
}
8989

90-
/**
91-
* Attempts a token update if any other time related conditionals have been reached based on the tokenRefresher class.
92-
*/
93-
private async updateTokenIfNeeded(options: GetTokenOptions): Promise<void> {
94-
if (this.tokenRefresher.isReady()) {
95-
const accessToken = await this.tokenRefresher.refresh(options);
96-
this.tokenCache.setCachedToken(accessToken);
97-
}
98-
}
99-
10090
private async getToken(options: GetTokenOptions): Promise<string | undefined> {
101-
let accessToken = this.tokenCache.getCachedToken();
102-
if (accessToken === undefined) {
103-
// Waiting for the next refresh only if the cache is unable to retrieve the access token,
104-
// which means that it has expired, or it has never been set.
105-
accessToken = await this.tokenRefresher.refresh(options);
106-
this.tokenCache.setCachedToken(accessToken);
107-
} else {
108-
// If we still have a cached access token,
109-
// And any other time related conditionals have been reached based on the tokenRefresher class,
110-
// then attempt to refresh without waiting.
111-
this.updateTokenIfNeeded(options);
91+
// We reset the cached token some before it expires,
92+
// after that point, we retry the refresh of the token only if the token refresher is ready.
93+
let token = this.tokenCache.getCachedToken();
94+
if (!token && this.tokenRefresher.isReady()) {
95+
token = await this.tokenRefresher.refresh(options);
96+
this.tokenCache.setCachedToken(token);
11297
}
113-
114-
return accessToken ? accessToken.token : undefined;
98+
return token ? token.token : undefined;
11599
}
116100
}

sdk/core/core-http/test/policies/bearerTokenAuthenticationPolicyTests.ts

Lines changed: 86 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// Licensed under the MIT license.
33

44
import { assert } from "chai";
5-
import { fake, createSandbox } from "sinon";
5+
import Sinon, { fake, createSandbox } from "sinon";
66
import { OperationSpec } from "../../src/operationSpec";
77
import { TokenCredential, GetTokenOptions, AccessToken } from "@azure/core-auth";
88
import { RequestPolicy, RequestPolicyOptions } from "../../src/policies/requestPolicy";
@@ -11,12 +11,15 @@ import { HttpOperationResponse } from "../../src/httpOperationResponse";
1111
import { HttpHeaders } from "../../src/httpHeaders";
1212
import { WebResource } from "../../src/webResource";
1313
import { BearerTokenAuthenticationPolicy } from "../../src/policies/bearerTokenAuthenticationPolicy";
14-
import {
15-
ExpiringAccessTokenCache,
16-
TokenRefreshBufferMs
17-
} from "../../src/credentials/accessTokenCache";
14+
import { ExpiringAccessTokenCache } from "../../src/credentials/accessTokenCache";
1815
import { AccessTokenRefresher } from "../../src/credentials/accessTokenRefresher";
1916

17+
// Token is refreshed one millisecond BEFORE it expires.
18+
const beforeTokenExpiresInMs = 1000;
19+
20+
// To avoid many refresh requests, we make new refresh requests only after:
21+
const tokenRefreshIntervalInMs = 500;
22+
2023
describe("BearerTokenAuthenticationPolicy", function() {
2124
const mockPolicy: RequestPolicy = {
2225
sendRequest(request: WebResource): Promise<HttpOperationResponse> {
@@ -28,6 +31,15 @@ describe("BearerTokenAuthenticationPolicy", function() {
2831
}
2932
};
3033

34+
let clock: Sinon.SinonFakeTimers;
35+
36+
beforeEach(() => {
37+
clock = Sinon.useFakeTimers(Date.now());
38+
});
39+
afterEach(() => {
40+
clock.restore();
41+
});
42+
3143
it("correctly adds an Authentication header with the Bearer token", async function() {
3244
const mockToken = "token";
3345
const tokenScopes = ["scope1", "scope2"];
@@ -53,32 +65,11 @@ describe("BearerTokenAuthenticationPolicy", function() {
5365
);
5466
});
5567

56-
it("refreshes access tokens when they expire", async () => {
57-
const now = Date.now();
58-
const refreshCred1 = new MockRefreshAzureCredential(now);
59-
const refreshCred2 = new MockRefreshAzureCredential(now + TokenRefreshBufferMs);
60-
const notRefreshCred1 = new MockRefreshAzureCredential(now + TokenRefreshBufferMs + 5000);
61-
62-
const credentialsToTest: [MockRefreshAzureCredential, number, string][] = [
63-
[refreshCred1, 2, "Refresh credential 1"],
64-
[refreshCred2, 2, "Refresh credential 2"],
65-
[notRefreshCred1, 1, "Not refresh credential 1"]
66-
];
67-
68-
const request = createRequest();
69-
for (const [credentialToTest, expectedCalls, message] of credentialsToTest) {
70-
const policy = createBearerTokenPolicy("testscope", credentialToTest);
71-
await policy.sendRequest(request);
72-
await policy.sendRequest(request);
73-
assert.strictEqual(credentialToTest.authCount, expectedCalls, `${message} failed`);
74-
}
75-
});
76-
7768
it("tests that AccessTokenRefresher is working", async function() {
7869
const now = Date.now();
7970
const credentialToTest = new MockRefreshAzureCredential(now);
8071
const request = createRequest();
81-
const policy = createBearerTokenPolicy("testscope", credentialToTest);
72+
const policy = createBearerTokenPolicy("test-scope", credentialToTest);
8273
await policy.sendRequest(request);
8374

8475
const sandbox = createSandbox();
@@ -88,6 +79,66 @@ describe("BearerTokenAuthenticationPolicy", function() {
8879
assert.strictEqual(credentialToTest.authCount, 2);
8980
});
9081

82+
it("refreshes the token on initial request", async () => {
83+
const expiresOn = Date.now() + 1000 * 60; // One minute later.
84+
const credential = new MockRefreshAzureCredential(expiresOn);
85+
const request = createRequest();
86+
const policy = createBearerTokenPolicy("test-scope", credential);
87+
88+
await policy.sendRequest(request);
89+
assert.strictEqual(credential.authCount, 1);
90+
});
91+
92+
it("refreshes the token again only once it expired", async () => {
93+
const expireDelayMs = 5000;
94+
let tokenExpiration = Date.now() + expireDelayMs;
95+
const credential = new MockRefreshAzureCredential(tokenExpiration);
96+
const request = createRequest();
97+
const policy = createBearerTokenPolicy("test-scope", credential);
98+
99+
// The token is cached and remains cached for a bit.
100+
await policy.sendRequest(request);
101+
await policy.sendRequest(request);
102+
assert.strictEqual(credential.authCount, 1);
103+
104+
// The token will remain cached until tokenExpiration - testTokenRefreshBufferMs, so in (5000 - 1000) milliseconds.
105+
106+
// For safe measure, we test the token is still cached a second earlier than the refresh expectation.
107+
clock.tick(expireDelayMs - beforeTokenExpiresInMs - 1000);
108+
await policy.sendRequest(request);
109+
assert.strictEqual(credential.authCount, 1);
110+
111+
// The new token will last 5 seconds again.
112+
tokenExpiration = Date.now() + expireDelayMs;
113+
credential.setExpiresOnTimestamp(tokenExpiration);
114+
115+
// Now we wait until it expires:
116+
clock.tick(1000);
117+
await policy.sendRequest(request);
118+
assert.strictEqual(credential.authCount, 2);
119+
});
120+
121+
it("access token refresher should prevent refreshers to happen too fast", async () => {
122+
const expiresOn = Date.now(); // Already expired
123+
const credential = new MockRefreshAzureCredential(expiresOn);
124+
const request = createRequest();
125+
const policy = createBearerTokenPolicy("test-scope", credential);
126+
127+
await policy.sendRequest(request);
128+
assert.strictEqual(credential.authCount, 1);
129+
credential.setExpiresOnTimestamp(Date.now());
130+
131+
await policy.sendRequest(request);
132+
assert.strictEqual(credential.authCount, 1);
133+
credential.setExpiresOnTimestamp(Date.now());
134+
135+
clock.tick(tokenRefreshIntervalInMs);
136+
137+
await policy.sendRequest(request);
138+
assert.strictEqual(credential.authCount, 2);
139+
credential.setExpiresOnTimestamp(Date.now());
140+
});
141+
91142
function createRequest(operationSpec?: OperationSpec): WebResource {
92143
const request = new WebResource();
93144
request.operationSpec = operationSpec;
@@ -101,8 +152,8 @@ describe("BearerTokenAuthenticationPolicy", function() {
101152
return new BearerTokenAuthenticationPolicy(
102153
mockPolicy,
103154
new RequestPolicyOptions(),
104-
new ExpiringAccessTokenCache(),
105-
new AccessTokenRefresher(credential, scopes)
155+
new ExpiringAccessTokenCache(beforeTokenExpiresInMs),
156+
new AccessTokenRefresher(credential, scopes, tokenRefreshIntervalInMs)
106157
);
107158
}
108159
});
@@ -115,11 +166,15 @@ class MockRefreshAzureCredential implements TokenCredential {
115166
this._expiresOnTimestamp = expiresOnTimestamp;
116167
}
117168

118-
public getToken(
169+
public setExpiresOnTimestamp(expiresOnTimestamp: number): void {
170+
this._expiresOnTimestamp = expiresOnTimestamp;
171+
}
172+
173+
public async getToken(
119174
_scopes: string | string[],
120175
_options?: GetTokenOptions
121176
): Promise<AccessToken | null> {
122177
this.authCount++;
123-
return Promise.resolve({ token: "mocktoken", expiresOnTimestamp: this._expiresOnTimestamp });
178+
return { token: "mock-token", expiresOnTimestamp: this._expiresOnTimestamp };
124179
}
125180
}

0 commit comments

Comments
 (0)