Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion sdk/communication/communication-common/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
25 changes: 18 additions & 7 deletions sdk/communication/communication-common/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"),
});
```

Expand All @@ -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,
});
```

Expand All @@ -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)
Expand All @@ -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)
2 changes: 1 addition & 1 deletion sdk/communication/communication-common/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;

Expand All @@ -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<string>;
private readonly refreshProactively: boolean;
private readonly refreshingIntervalInMs: number = defaultRefreshingInterval;
private readonly expiringSoonIntervalInMs: number = defaultExpiringSoonInterval;
private readonly refreshAfterLifetimePercentage = defaultRefreshAfterLifetimePercentage;

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

public async getToken(options?: CommunicationGetTokenOptions): Promise<AccessToken> {
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;
}

Expand Down Expand Up @@ -90,7 +92,13 @@ export class AutoRefreshTokenCredential implements TokenCredential {
}

private async refreshTokenAndReschedule(abortSignal?: AbortSignalLike): Promise<void> {
this.currentToken = await this.refreshToken(abortSignal);
const newToken = await this.refreshToken(abortSignal);

if (!this.isTokenValid(newToken)) {
Comment thread
petrsvihlik marked this conversation as resolved.
throw new Error("The token returned from the tokenRefresher is expired.");
}

this.currentToken = newToken;
if (this.refreshProactively) {
this.scheduleRefresh();
}
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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,
Expand All @@ -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();

Expand All @@ -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);
});
Expand All @@ -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);
Expand All @@ -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);
});
Expand All @@ -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);
});
});