diff --git a/sdk/communication/communication-common/CHANGELOG.md b/sdk/communication/communication-common/CHANGELOG.md index 6225d3619b33..379009cee429 100644 --- a/sdk/communication/communication-common/CHANGELOG.md +++ b/sdk/communication/communication-common/CHANGELOG.md @@ -1,9 +1,11 @@ # Release History -## 1.1.1 (Unreleased) +## 1.2.0 (Unreleased) ### Features Added +- 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. + ### Breaking Changes ### Bugs Fixed diff --git a/sdk/communication/communication-common/README.md b/sdk/communication/communication-common/README.md index f70f5aa82360..86eb1cb5ba59 100644 --- a/sdk/communication/communication-common/README.md +++ b/sdk/communication/communication-common/README.md @@ -25,16 +25,23 @@ To use this client library in the browser, first you need to use a bundler. For ### CommunicationTokenCredential and AzureCommunicationTokenCredential -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. +The `CommunicationTokenCredential` is an interface used to authenticate a user with Communication Services, such as Chat or Calling. -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`. +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. -`CommunicationTokenCredential` is only the interface, please always use the `AzureCommunicationTokenCredential` constructor to create a credential and take advantage of the built-in refresh logic. +Depending on your scenario, you may want to initialize the `AzureCommunicationTokenCredential` with: + +- a static token (suitable for short-lived clients used to e.g. send one-off Chat messages) or +- a callback function that ensures a continuous authentication state during communications (ideal e.g. for long Calling sessions). + +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. ## Examples ### Create a credential with a static token +For a short-lived clients, refreshing the token upon expiry is not necessary and the `AzureCommunicationTokenCredential` may be instantiated with a static token. + ```typescript const tokenCredential = new AzureCommunicationTokenCredential( "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjM2MDB9.adM-ddBZZlQ1WlN3pdPBOF5G4Wh9iZpxNP_fSvpF4cWs" @@ -43,11 +50,11 @@ const tokenCredential = new AzureCommunicationTokenCredential( ### Create a credential with a callback -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. +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. ```typescript const tokenCredential = new AzureCommunicationTokenCredential({ - tokenRefresher: async () => fetchTokenFromMyServerForUser("bob@contoso.com") + tokenRefresher: async () => fetchTokenFromMyServerForUser("bob@contoso.com"), }); ``` @@ -58,7 +65,7 @@ Setting `refreshProactively` to true will call your `tokenRefresher` function wh ```typescript const tokenCredential = new AzureCommunicationTokenCredential({ tokenRefresher: async () => fetchTokenFromMyServerForUser("bob@contoso.com"), - refreshProactively: true + refreshProactively: true, }); ``` @@ -71,12 +78,14 @@ const tokenCredential = new AzureCommunicationTokenCredential({ tokenRefresher: async () => fetchTokenFromMyServerForUser("bob@contoso.com"), refreshProactively: true, token: - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjM2MDB9.adM-ddBZZlQ1WlN3pdPBOF5G4Wh9iZpxNP_fSvpF4cWs" + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjM2MDB9.adM-ddBZZlQ1WlN3pdPBOF5G4Wh9iZpxNP_fSvpF4cWs", }); ``` ## Troubleshooting +- **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. + ## Next steps - [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 [azure_sub]: https://azure.microsoft.com/free/ [azure_portal]: https://portal.azure.com [azure_powershell]: https://docs.microsoft.com/powershell/module/az.communication/new-azcommunicationservice +[invalid_token_sdk]: https://docs.microsoft.com/javascript/api/@azure/communication-identity/communicationaccesstoken#@azure-communication-identity-communicationaccesstoken-token +[invalid_token_rest]: https://docs.microsoft.com/rest/api/communication/communicationidentity/communication-identity/issue-access-token#communicationidentityaccesstoken ![Impressions](https://azure-sdk-impressions.azurewebsites.net/api/impressions/azure-sdk-for-js%2Fsdk%2Fcommunication%2Fcommunication-sms%2FREADME.png) diff --git a/sdk/communication/communication-common/package.json b/sdk/communication/communication-common/package.json index 4f24e0616cd7..39a0c1dd40f5 100644 --- a/sdk/communication/communication-common/package.json +++ b/sdk/communication/communication-common/package.json @@ -1,6 +1,6 @@ { "name": "@azure/communication-common", - "version": "1.1.1", + "version": "1.2.0", "description": "Common package for Azure Communication services.", "sdk-type": "client", "main": "dist/index.js", diff --git a/sdk/communication/communication-common/src/autoRefreshTokenCredential.ts b/sdk/communication/communication-common/src/autoRefreshTokenCredential.ts index cbeab9a2783a..c6896faf08a2 100644 --- a/sdk/communication/communication-common/src/autoRefreshTokenCredential.ts +++ b/sdk/communication/communication-common/src/autoRefreshTokenCredential.ts @@ -10,7 +10,8 @@ import { TokenCredential, CommunicationGetTokenOptions } from "./communicationTo */ export interface CommunicationTokenRefreshOptions { /** - * Function that returns a token acquired from the Communication configuration SDK. + * Callback function that returns a string JWT token acquired from the Communication Identity API. + * The returned token must be valid (expiration date must be in the future). */ tokenRefresher: (abortSignal?: AbortSignalLike) => Promise; @@ -28,12 +29,14 @@ export interface CommunicationTokenRefreshOptions { const expiredToken = { token: "", expiresOnTimestamp: -10 }; const minutesToMs = (minutes: number): number => minutes * 1000 * 60; -const defaultRefreshingInterval = minutesToMs(10); +const defaultExpiringSoonInterval = minutesToMs(10); +const defaultRefreshAfterLifetimePercentage = 0.5; export class AutoRefreshTokenCredential implements TokenCredential { private readonly refresh: (abortSignal?: AbortSignalLike) => Promise; private readonly refreshProactively: boolean; - private readonly refreshingIntervalInMs: number = defaultRefreshingInterval; + private readonly expiringSoonIntervalInMs: number = defaultExpiringSoonInterval; + private readonly refreshAfterLifetimePercentage = defaultRefreshAfterLifetimePercentage; private currentToken: AccessToken; private activeTimeout: ReturnType | undefined; @@ -54,13 +57,12 @@ export class AutoRefreshTokenCredential implements TokenCredential { } public async getToken(options?: CommunicationGetTokenOptions): Promise { - if (!this.isCurrentTokenExpiringSoon) { + if (!this.isTokenExpiringSoon(this.currentToken)) { return this.currentToken; } - const updatePromise = this.updateTokenAndReschedule(options?.abortSignal); - - if (!this.isCurrentTokenValid) { + if (!this.isTokenValid(this.currentToken)) { + const updatePromise = this.updateTokenAndReschedule(options?.abortSignal); await updatePromise; } @@ -90,7 +92,13 @@ export class AutoRefreshTokenCredential implements TokenCredential { } private async refreshTokenAndReschedule(abortSignal?: AbortSignalLike): Promise { - this.currentToken = await this.refreshToken(abortSignal); + const newToken = await this.refreshToken(abortSignal); + + if (!this.isTokenValid(newToken)) { + throw new Error("The token returned from the tokenRefresher is expired."); + } + + this.currentToken = newToken; if (this.refreshProactively) { this.scheduleRefresh(); } @@ -114,19 +122,25 @@ export class AutoRefreshTokenCredential implements TokenCredential { if (this.activeTimeout) { clearTimeout(this.activeTimeout); } - const timespanInMs = - this.currentToken.expiresOnTimestamp - Date.now() - this.refreshingIntervalInMs; + const tokenTtlInMs = this.currentToken.expiresOnTimestamp - Date.now(); + let timespanInMs = null; + + if (this.isTokenExpiringSoon(this.currentToken)) { + // Schedule the next refresh for when it reaches a certain percentage of the remaining lifetime. + timespanInMs = tokenTtlInMs * this.refreshAfterLifetimePercentage; + } else { + // Schedule the next refresh for when it gets in to the soon-to-expire window. + timespanInMs = tokenTtlInMs - this.expiringSoonIntervalInMs; + } + this.activeTimeout = setTimeout(() => this.updateTokenAndReschedule(), timespanInMs); } - private get isCurrentTokenValid(): boolean { - return this.currentToken && Date.now() < this.currentToken.expiresOnTimestamp; + private isTokenValid(token: AccessToken): boolean { + return token && Date.now() < token.expiresOnTimestamp; } - private get isCurrentTokenExpiringSoon(): boolean { - return ( - !this.currentToken || - Date.now() >= this.currentToken.expiresOnTimestamp - this.refreshingIntervalInMs - ); + private isTokenExpiringSoon(token: AccessToken): boolean { + return !token || Date.now() >= token.expiresOnTimestamp - this.expiringSoonIntervalInMs; } } diff --git a/sdk/communication/communication-common/test/communicationTokenCredential.spec.ts b/sdk/communication/communication-common/test/communicationTokenCredential.spec.ts index 7e566d0f94f7..7c28bc4d3327 100644 --- a/sdk/communication/communication-common/test/communicationTokenCredential.spec.ts +++ b/sdk/communication/communication-common/test/communicationTokenCredential.spec.ts @@ -25,6 +25,16 @@ const exposeInternalTimeout = ( return ((tokenCredential as any).tokenCredential as any).activeTimeout; }; +const getDivisionWithoutFractionCount = (dividend: number, divisor: number): number => { + let i = dividend; + let result = 0; + while (i >= divisor) { + i = Math.round(i / divisor); + result++; + } + return result; +}; + const exposeInternalUpdatePromise = async ( tokenCredential: AzureCommunicationTokenCredential ): Promise => { @@ -101,13 +111,17 @@ describe("CommunicationTokenCredential", () => { sinon.assert.notCalled(tokenRefresher); }); - it("with proactive refresh, passing an expired token triggers immediate refresh", async () => { + it("throws if tokenRefresher returns an expired token", async () => { const tokenRefresher = sinon.stub().resolves(generateToken(-1)); - new AzureCommunicationTokenCredential({ - tokenRefresher, - refreshProactively: true, + const credential = new AzureCommunicationTokenCredential({ + tokenRefresher: tokenRefresher, }); clock.tick(5 * 60 * 1000); + await assert.isRejected( + credential.getToken(), + Error, + "The token returned from the tokenRefresher is expired." + ); sinon.assert.calledOnce(tokenRefresher); }); @@ -139,7 +153,7 @@ describe("CommunicationTokenCredential", () => { await assert.isRejected(withLambda.getToken()); }); - it("doesn't swallow error from tokenrefresher", async () => { + it("doesn't swallow error from tokenRefresher", async () => { const tokenRefresher = sinon.stub().throws(new Error("No token for you!")); const tokenCredential = new AzureCommunicationTokenCredential({ tokenRefresher, @@ -157,7 +171,7 @@ describe("CommunicationTokenCredential", () => { assert.strictEqual(tokenResult.token, token); tokenRefresher.resolves(newToken); - // go into soon to expire window + // go into the soon-to-expire window clock.tick(19 * 60 * 1000); const secondTokenResult = await tokenCredential.getToken(); @@ -181,7 +195,7 @@ describe("CommunicationTokenCredential", () => { token: token(), }); - // go into soon to expire window + // go into the soon-to-expire window clock.tick(19 * 60 * 1000); sinon.assert.calledOnce(tokenRefresher); }); @@ -196,7 +210,7 @@ describe("CommunicationTokenCredential", () => { }); const internalTimeout = exposeInternalTimeout(tokenCredential); - // go into soon to expire window + // go into the soon-to-expire window clock.tick(19 * 60 * 1000); await exposeInternalUpdatePromise(tokenCredential); @@ -217,7 +231,7 @@ describe("CommunicationTokenCredential", () => { }); tokenCredential.dispose(); - // go into soon to expire window + // go into the soon-to-expire window clock.tick(19 * 60 * 1000); sinon.assert.notCalled(tokenRefresher); }); @@ -240,9 +254,53 @@ describe("CommunicationTokenCredential", () => { refreshProactively: true, }); - // go into soon to expire window + // go into the soon-to-expire window clock.tick(19 * 60 * 1000); await tokenCredential.getToken(); sinon.assert.calledOnce(tokenRefresher); }); + + it("applies fractional backoff when the token is about to expire", async () => { + const defaultRefreshAfterLifetimePercentage = 0.5; + const tokenExpiration = 20; + const expectedPreBackOffCallCount = 1; + const lastMsCall = 1; + const expectedTotalCallCount = + expectedPreBackOffCallCount + + getDivisionWithoutFractionCount( + tokenExpiration * 60 * 1000, + 1 / defaultRefreshAfterLifetimePercentage + ) - + lastMsCall; + const staticToken = generateToken(tokenExpiration); + const tokenRefresher = sinon.stub().resolves(((): string => staticToken)()); // keep returning the same token for the duration of the test + const tokenCredential = new AzureCommunicationTokenCredential({ + tokenRefresher, + refreshProactively: true, + token: staticToken, + }); + + const newToken = await tokenCredential.getToken(); + + // go into the soon-to-expire window + for (let i = 0; i < 10 * 60 * 1000; i++) { + // perform token refreshing & scheduling + await exposeInternalUpdatePromise(tokenCredential); + clock.tick(1); + } + + // expect the token to be refreshed only once within the first 10 minutes + sinon.assert.callCount(tokenRefresher, expectedPreBackOffCallCount); + + // iterate until the penultimate millisecond of the token expiration + // to prevent an exception being thrown due to the token being expired + while (newToken.expiresOnTimestamp - Date.now() > lastMsCall) { + // perform token refreshing & scheduling + await exposeInternalUpdatePromise(tokenCredential); + clock.tick(1); + } + + // expect the token to be refreshed approx. Math.floor(Math.log(tokenExpirationInMs) / Math.log(2)) times + sinon.assert.callCount(tokenRefresher, expectedTotalCallCount); + }); });