Skip to content

Commit d53738f

Browse files
authored
Implemented a backoff mechanism for the AutoRefreshTokenCredential (Azure#19804)
* initial backoff implementation * added test for backoff * improved the autorefresh documentation * better explained scenarios * clarify tokenRefresher's expected return type
1 parent d0cdbf9 commit d53738f

File tree

5 files changed

+121
-36
lines changed

5 files changed

+121
-36
lines changed

sdk/communication/communication-common/CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
# Release History
22

3-
## 1.1.1 (Unreleased)
3+
## 1.2.0 (Unreleased)
44

55
### Features Added
66

7+
- Optimization added: When the proactive refreshing is enabled and the token refresher fails to provide a token that's not about to expire soon, the subsequent refresh attempts will be scheduled for when the token reaches half of its remaining lifetime until a token with long enough validity (>10 minutes) is obtained.
8+
79
### Breaking Changes
810

911
### Bugs Fixed

sdk/communication/communication-common/README.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,23 @@ To use this client library in the browser, first you need to use a bundler. For
2525

2626
### CommunicationTokenCredential and AzureCommunicationTokenCredential
2727

28-
A `CommunicationTokenCredential` authenticates a user with Communication Services, such as Chat or Calling. It optionally provides an auto-refresh mechanism to ensure a continuously stable authentication state during communications.
28+
The `CommunicationTokenCredential` is an interface used to authenticate a user with Communication Services, such as Chat or Calling.
2929

30-
It is up to you the developer to first create valid user tokens with the Azure Communication Administration library. Then you use these tokens to create a `AzureCommunicationTokenCredential`.
30+
The `AzureCommunicationTokenCredential` offers a convenient way to create a credential implementing the said interface and allows you to take advantage of the built-in auto-refresh logic.
3131

32-
`CommunicationTokenCredential` is only the interface, please always use the `AzureCommunicationTokenCredential` constructor to create a credential and take advantage of the built-in refresh logic.
32+
Depending on your scenario, you may want to initialize the `AzureCommunicationTokenCredential` with:
33+
34+
- a static token (suitable for short-lived clients used to e.g. send one-off Chat messages) or
35+
- a callback function that ensures a continuous authentication state during communications (ideal e.g. for long Calling sessions).
36+
37+
The tokens supplied to the `AzureCommunicationTokenCredential` either through the constructor or via the token refresher callback can be obtained using the Azure Communication Identity library.
3338

3439
## Examples
3540

3641
### Create a credential with a static token
3742

43+
For a short-lived clients, refreshing the token upon expiry is not necessary and the `AzureCommunicationTokenCredential` may be instantiated with a static token.
44+
3845
```typescript
3946
const tokenCredential = new AzureCommunicationTokenCredential(
4047
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjM2MDB9.adM-ddBZZlQ1WlN3pdPBOF5G4Wh9iZpxNP_fSvpF4cWs"
@@ -43,11 +50,11 @@ const tokenCredential = new AzureCommunicationTokenCredential(
4350

4451
### Create a credential with a callback
4552

46-
Here we assume that we have a function `fetchTokenFromMyServerForUser` that makes a network request to retrieve a token string for a user. We pass it into the credential to fetch a token for Bob from our own server. Our server would use the Azure Communication Administration library to issue tokens.
53+
Here we assume that we have a function `fetchTokenFromMyServerForUser` that makes a network request to retrieve a JWT token string for a user. We pass it into the credential to fetch a token for Bob from our own server. Our server would use the Azure Communication Identity library to issue tokens. It's necessary that the `fetchTokenFromMyServerForUser` function returns a valid token (with an expiration date set in the future) at all times.
4754

4855
```typescript
4956
const tokenCredential = new AzureCommunicationTokenCredential({
50-
tokenRefresher: async () => fetchTokenFromMyServerForUser("[email protected]")
57+
tokenRefresher: async () => fetchTokenFromMyServerForUser("[email protected]"),
5158
});
5259
```
5360

@@ -58,7 +65,7 @@ Setting `refreshProactively` to true will call your `tokenRefresher` function wh
5865
```typescript
5966
const tokenCredential = new AzureCommunicationTokenCredential({
6067
tokenRefresher: async () => fetchTokenFromMyServerForUser("[email protected]"),
61-
refreshProactively: true
68+
refreshProactively: true,
6269
});
6370
```
6471

@@ -71,12 +78,14 @@ const tokenCredential = new AzureCommunicationTokenCredential({
7178
tokenRefresher: async () => fetchTokenFromMyServerForUser("[email protected]"),
7279
refreshProactively: true,
7380
token:
74-
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjM2MDB9.adM-ddBZZlQ1WlN3pdPBOF5G4Wh9iZpxNP_fSvpF4cWs"
81+
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjM2MDB9.adM-ddBZZlQ1WlN3pdPBOF5G4Wh9iZpxNP_fSvpF4cWs",
7582
});
7683
```
7784

7885
## Troubleshooting
7986

87+
- **Invalid token specified**: Make sure the token you are passing to the `AzureCommunicationTokenCredential` constructor or to the `tokenRefresher` callback is a bare JWT token string. E.g. if you're using the [Azure Communication Identity library][invalid_token_sdk] or [REST API][invalid_token_rest] to obtain the token, make sure you're passing just the `token` part of the response object.
88+
8089
## Next steps
8190

8291
- [Read more about Communication user access tokens](https://docs.microsoft.com/azure/communication-services/concepts/authentication?tabs=javascript)
@@ -93,5 +102,7 @@ If you'd like to contribute to this library, please read the [contributing guide
93102
[azure_sub]: https://azure.microsoft.com/free/
94103
[azure_portal]: https://portal.azure.com
95104
[azure_powershell]: https://docs.microsoft.com/powershell/module/az.communication/new-azcommunicationservice
105+
[invalid_token_sdk]: https://docs.microsoft.com/javascript/api/@azure/communication-identity/communicationaccesstoken#@azure-communication-identity-communicationaccesstoken-token
106+
[invalid_token_rest]: https://docs.microsoft.com/rest/api/communication/communicationidentity/communication-identity/issue-access-token#communicationidentityaccesstoken
96107

97108
![Impressions](https://azure-sdk-impressions.azurewebsites.net/api/impressions/azure-sdk-for-js%2Fsdk%2Fcommunication%2Fcommunication-sms%2FREADME.png)

sdk/communication/communication-common/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@azure/communication-common",
3-
"version": "1.1.1",
3+
"version": "1.2.0",
44
"description": "Common package for Azure Communication services.",
55
"sdk-type": "client",
66
"main": "dist/index.js",

sdk/communication/communication-common/src/autoRefreshTokenCredential.ts

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import { TokenCredential, CommunicationGetTokenOptions } from "./communicationTo
1010
*/
1111
export interface CommunicationTokenRefreshOptions {
1212
/**
13-
* Function that returns a token acquired from the Communication configuration SDK.
13+
* Callback function that returns a string JWT token acquired from the Communication Identity API.
14+
* The returned token must be valid (expiration date must be in the future).
1415
*/
1516
tokenRefresher: (abortSignal?: AbortSignalLike) => Promise<string>;
1617

@@ -28,12 +29,14 @@ export interface CommunicationTokenRefreshOptions {
2829

2930
const expiredToken = { token: "", expiresOnTimestamp: -10 };
3031
const minutesToMs = (minutes: number): number => minutes * 1000 * 60;
31-
const defaultRefreshingInterval = minutesToMs(10);
32+
const defaultExpiringSoonInterval = minutesToMs(10);
33+
const defaultRefreshAfterLifetimePercentage = 0.5;
3234

3335
export class AutoRefreshTokenCredential implements TokenCredential {
3436
private readonly refresh: (abortSignal?: AbortSignalLike) => Promise<string>;
3537
private readonly refreshProactively: boolean;
36-
private readonly refreshingIntervalInMs: number = defaultRefreshingInterval;
38+
private readonly expiringSoonIntervalInMs: number = defaultExpiringSoonInterval;
39+
private readonly refreshAfterLifetimePercentage = defaultRefreshAfterLifetimePercentage;
3740

3841
private currentToken: AccessToken;
3942
private activeTimeout: ReturnType<typeof setTimeout> | undefined;
@@ -54,13 +57,12 @@ export class AutoRefreshTokenCredential implements TokenCredential {
5457
}
5558

5659
public async getToken(options?: CommunicationGetTokenOptions): Promise<AccessToken> {
57-
if (!this.isCurrentTokenExpiringSoon) {
60+
if (!this.isTokenExpiringSoon(this.currentToken)) {
5861
return this.currentToken;
5962
}
6063

61-
const updatePromise = this.updateTokenAndReschedule(options?.abortSignal);
62-
63-
if (!this.isCurrentTokenValid) {
64+
if (!this.isTokenValid(this.currentToken)) {
65+
const updatePromise = this.updateTokenAndReschedule(options?.abortSignal);
6466
await updatePromise;
6567
}
6668

@@ -90,7 +92,13 @@ export class AutoRefreshTokenCredential implements TokenCredential {
9092
}
9193

9294
private async refreshTokenAndReschedule(abortSignal?: AbortSignalLike): Promise<void> {
93-
this.currentToken = await this.refreshToken(abortSignal);
95+
const newToken = await this.refreshToken(abortSignal);
96+
97+
if (!this.isTokenValid(newToken)) {
98+
throw new Error("The token returned from the tokenRefresher is expired.");
99+
}
100+
101+
this.currentToken = newToken;
94102
if (this.refreshProactively) {
95103
this.scheduleRefresh();
96104
}
@@ -114,19 +122,25 @@ export class AutoRefreshTokenCredential implements TokenCredential {
114122
if (this.activeTimeout) {
115123
clearTimeout(this.activeTimeout);
116124
}
117-
const timespanInMs =
118-
this.currentToken.expiresOnTimestamp - Date.now() - this.refreshingIntervalInMs;
125+
const tokenTtlInMs = this.currentToken.expiresOnTimestamp - Date.now();
126+
let timespanInMs = null;
127+
128+
if (this.isTokenExpiringSoon(this.currentToken)) {
129+
// Schedule the next refresh for when it reaches a certain percentage of the remaining lifetime.
130+
timespanInMs = tokenTtlInMs * this.refreshAfterLifetimePercentage;
131+
} else {
132+
// Schedule the next refresh for when it gets in to the soon-to-expire window.
133+
timespanInMs = tokenTtlInMs - this.expiringSoonIntervalInMs;
134+
}
135+
119136
this.activeTimeout = setTimeout(() => this.updateTokenAndReschedule(), timespanInMs);
120137
}
121138

122-
private get isCurrentTokenValid(): boolean {
123-
return this.currentToken && Date.now() < this.currentToken.expiresOnTimestamp;
139+
private isTokenValid(token: AccessToken): boolean {
140+
return token && Date.now() < token.expiresOnTimestamp;
124141
}
125142

126-
private get isCurrentTokenExpiringSoon(): boolean {
127-
return (
128-
!this.currentToken ||
129-
Date.now() >= this.currentToken.expiresOnTimestamp - this.refreshingIntervalInMs
130-
);
143+
private isTokenExpiringSoon(token: AccessToken): boolean {
144+
return !token || Date.now() >= token.expiresOnTimestamp - this.expiringSoonIntervalInMs;
131145
}
132146
}

sdk/communication/communication-common/test/communicationTokenCredential.spec.ts

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ const exposeInternalTimeout = (
2525
return ((tokenCredential as any).tokenCredential as any).activeTimeout;
2626
};
2727

28+
const getDivisionWithoutFractionCount = (dividend: number, divisor: number): number => {
29+
let i = dividend;
30+
let result = 0;
31+
while (i >= divisor) {
32+
i = Math.round(i / divisor);
33+
result++;
34+
}
35+
return result;
36+
};
37+
2838
const exposeInternalUpdatePromise = async (
2939
tokenCredential: AzureCommunicationTokenCredential
3040
): Promise<void> => {
@@ -101,13 +111,17 @@ describe("CommunicationTokenCredential", () => {
101111
sinon.assert.notCalled(tokenRefresher);
102112
});
103113

104-
it("with proactive refresh, passing an expired token triggers immediate refresh", async () => {
114+
it("throws if tokenRefresher returns an expired token", async () => {
105115
const tokenRefresher = sinon.stub().resolves(generateToken(-1));
106-
new AzureCommunicationTokenCredential({
107-
tokenRefresher,
108-
refreshProactively: true,
116+
const credential = new AzureCommunicationTokenCredential({
117+
tokenRefresher: tokenRefresher,
109118
});
110119
clock.tick(5 * 60 * 1000);
120+
await assert.isRejected(
121+
credential.getToken(),
122+
Error,
123+
"The token returned from the tokenRefresher is expired."
124+
);
111125
sinon.assert.calledOnce(tokenRefresher);
112126
});
113127

@@ -139,7 +153,7 @@ describe("CommunicationTokenCredential", () => {
139153
await assert.isRejected(withLambda.getToken());
140154
});
141155

142-
it("doesn't swallow error from tokenrefresher", async () => {
156+
it("doesn't swallow error from tokenRefresher", async () => {
143157
const tokenRefresher = sinon.stub().throws(new Error("No token for you!"));
144158
const tokenCredential = new AzureCommunicationTokenCredential({
145159
tokenRefresher,
@@ -157,7 +171,7 @@ describe("CommunicationTokenCredential", () => {
157171
assert.strictEqual(tokenResult.token, token);
158172

159173
tokenRefresher.resolves(newToken);
160-
// go into soon to expire window
174+
// go into the soon-to-expire window
161175
clock.tick(19 * 60 * 1000);
162176
const secondTokenResult = await tokenCredential.getToken();
163177

@@ -181,7 +195,7 @@ describe("CommunicationTokenCredential", () => {
181195
token: token(),
182196
});
183197

184-
// go into soon to expire window
198+
// go into the soon-to-expire window
185199
clock.tick(19 * 60 * 1000);
186200
sinon.assert.calledOnce(tokenRefresher);
187201
});
@@ -196,7 +210,7 @@ describe("CommunicationTokenCredential", () => {
196210
});
197211

198212
const internalTimeout = exposeInternalTimeout(tokenCredential);
199-
// go into soon to expire window
213+
// go into the soon-to-expire window
200214
clock.tick(19 * 60 * 1000);
201215

202216
await exposeInternalUpdatePromise(tokenCredential);
@@ -217,7 +231,7 @@ describe("CommunicationTokenCredential", () => {
217231
});
218232

219233
tokenCredential.dispose();
220-
// go into soon to expire window
234+
// go into the soon-to-expire window
221235
clock.tick(19 * 60 * 1000);
222236
sinon.assert.notCalled(tokenRefresher);
223237
});
@@ -240,9 +254,53 @@ describe("CommunicationTokenCredential", () => {
240254
refreshProactively: true,
241255
});
242256

243-
// go into soon to expire window
257+
// go into the soon-to-expire window
244258
clock.tick(19 * 60 * 1000);
245259
await tokenCredential.getToken();
246260
sinon.assert.calledOnce(tokenRefresher);
247261
});
262+
263+
it("applies fractional backoff when the token is about to expire", async () => {
264+
const defaultRefreshAfterLifetimePercentage = 0.5;
265+
const tokenExpiration = 20;
266+
const expectedPreBackOffCallCount = 1;
267+
const lastMsCall = 1;
268+
const expectedTotalCallCount =
269+
expectedPreBackOffCallCount +
270+
getDivisionWithoutFractionCount(
271+
tokenExpiration * 60 * 1000,
272+
1 / defaultRefreshAfterLifetimePercentage
273+
) -
274+
lastMsCall;
275+
const staticToken = generateToken(tokenExpiration);
276+
const tokenRefresher = sinon.stub().resolves(((): string => staticToken)()); // keep returning the same token for the duration of the test
277+
const tokenCredential = new AzureCommunicationTokenCredential({
278+
tokenRefresher,
279+
refreshProactively: true,
280+
token: staticToken,
281+
});
282+
283+
const newToken = await tokenCredential.getToken();
284+
285+
// go into the soon-to-expire window
286+
for (let i = 0; i < 10 * 60 * 1000; i++) {
287+
// perform token refreshing & scheduling
288+
await exposeInternalUpdatePromise(tokenCredential);
289+
clock.tick(1);
290+
}
291+
292+
// expect the token to be refreshed only once within the first 10 minutes
293+
sinon.assert.callCount(tokenRefresher, expectedPreBackOffCallCount);
294+
295+
// iterate until the penultimate millisecond of the token expiration
296+
// to prevent an exception being thrown due to the token being expired
297+
while (newToken.expiresOnTimestamp - Date.now() > lastMsCall) {
298+
// perform token refreshing & scheduling
299+
await exposeInternalUpdatePromise(tokenCredential);
300+
clock.tick(1);
301+
}
302+
303+
// expect the token to be refreshed approx. Math.floor(Math.log(tokenExpirationInMs) / Math.log(2)) times
304+
sinon.assert.callCount(tokenRefresher, expectedTotalCallCount);
305+
});
248306
});

0 commit comments

Comments
 (0)