Skip to content

Commit db3fe23

Browse files
authored
[core-rest-pipeline] Token refresher update, based on the latest design by Will (Azure#14554)
* [core-rest-pipeline] Token refresher update, based on the latest design by Will * formatting and a file I missed * timeoutInMs to refreshTimeout * lint warning fix * CHANGELOG entry
1 parent 8fab57e commit db3fe23

File tree

4 files changed

+393
-33
lines changed

4 files changed

+393
-33
lines changed

sdk/core/core-rest-pipeline/CHANGELOG.md

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

33
## 1.0.4 (Unreleased)
44

5+
- Rewrote `bearerTokenAuthenticationPolicy` to use a new backend that refreshes tokens only when they're about to expire and not multiple times before. This is based on a similar fix implemented on `@azure/core-http@1.2.4` ([PR with the changes](https://github.com/Azure/azure-sdk-for-js/pull/14223)). This fixes the issue: [13369](https://github.com/Azure/azure-sdk-for-js/issues/13369).
56

67
## 1.0.3 (2021-03-30)
78

sdk/core/core-rest-pipeline/src/policies/bearerTokenAuthenticationPolicy.ts

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33

44
import { PipelineResponse, PipelineRequest, SendRequest } from "../interfaces";
55
import { PipelinePolicy } from "../pipeline";
6-
import { TokenCredential, GetTokenOptions } from "@azure/core-auth";
7-
import { AccessTokenCache, ExpiringAccessTokenCache } from "../accessTokenCache";
6+
import { TokenCredential } from "@azure/core-auth";
7+
import { createTokenCycler } from "../util/tokenCycler";
88

99
/**
1010
* The programmatic identifier of the bearerTokenAuthenticationPolicy.
@@ -33,20 +33,17 @@ export function bearerTokenAuthenticationPolicy(
3333
options: BearerTokenAuthenticationPolicyOptions
3434
): PipelinePolicy {
3535
const { credential, scopes } = options;
36-
const tokenCache: AccessTokenCache = new ExpiringAccessTokenCache();
37-
async function getToken(tokenOptions: GetTokenOptions): Promise<string | undefined> {
38-
let accessToken = tokenCache.getCachedToken();
39-
if (accessToken === undefined) {
40-
accessToken = (await credential.getToken(scopes, tokenOptions)) || undefined;
41-
tokenCache.setCachedToken(accessToken);
42-
}
4336

44-
return accessToken ? accessToken.token : undefined;
45-
}
37+
// This function encapsulates the entire process of reliably retrieving the token
38+
// The options are left out of the public API until there's demand to configure this.
39+
// Remember to extend `BearerTokenAuthenticationPolicyOptions` with `TokenCyclerOptions`
40+
// in order to pass through the `options` object.
41+
const getToken = createTokenCycler(credential, scopes /* , options */);
42+
4643
return {
4744
name: bearerTokenAuthenticationPolicyName,
4845
async sendRequest(request: PipelineRequest, next: SendRequest): Promise<PipelineResponse> {
49-
const token = await getToken({
46+
const { token } = await getToken({
5047
abortSignal: request.abortSignal,
5148
tracingOptions: request.tracingOptions
5249
});
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import { AccessToken, GetTokenOptions, TokenCredential } from "@azure/core-auth";
5+
import { delay } from "./helpers";
6+
7+
/**
8+
* A function that gets a promise of an access token and allows providing
9+
* options.
10+
*
11+
* @param options - the options to pass to the underlying token provider
12+
*/
13+
type AccessTokenGetter = (options: GetTokenOptions) => Promise<AccessToken>;
14+
15+
export interface TokenCyclerOptions {
16+
/**
17+
* The window of time before token expiration during which the token will be
18+
* considered unusable due to risk of the token expiring before sending the
19+
* request.
20+
*
21+
* This will only become meaningful if the refresh fails for over
22+
* (refreshWindow - forcedRefreshWindow) milliseconds.
23+
*/
24+
forcedRefreshWindowInMs: number;
25+
/**
26+
* Interval in milliseconds to retry failed token refreshes.
27+
*/
28+
retryIntervalInMs: number;
29+
/**
30+
* The window of time before token expiration during which
31+
* we will attempt to refresh the token.
32+
*/
33+
refreshWindowInMs: number;
34+
}
35+
36+
// Default options for the cycler if none are provided
37+
export const DEFAULT_CYCLER_OPTIONS: TokenCyclerOptions = {
38+
forcedRefreshWindowInMs: 1000, // Force waiting for a refresh 1s before the token expires
39+
retryIntervalInMs: 3000, // Allow refresh attempts every 3s
40+
refreshWindowInMs: 1000 * 60 * 2 // Start refreshing 2m before expiry
41+
};
42+
43+
/**
44+
* Converts an an unreliable access token getter (which may resolve with null)
45+
* into an AccessTokenGetter by retrying the unreliable getter in a regular
46+
* interval.
47+
*
48+
* @param getAccessToken - A function that produces a promise of an access token that may fail by returning null.
49+
* @param retryIntervalInMs - The time (in milliseconds) to wait between retry attempts.
50+
* @param refreshTimeout - The timestamp after which the refresh attempt will fail, throwing an exception.
51+
* @returns - A promise that, if it resolves, will resolve with an access token.
52+
*/
53+
async function beginRefresh(
54+
getAccessToken: () => Promise<AccessToken | null>,
55+
retryIntervalInMs: number,
56+
refreshTimeout: number
57+
): Promise<AccessToken> {
58+
// This wrapper handles exceptions gracefully as long as we haven't exceeded
59+
// the timeout.
60+
async function tryGetAccessToken(): Promise<AccessToken | null> {
61+
if (Date.now() < refreshTimeout) {
62+
try {
63+
return await getAccessToken();
64+
} catch {
65+
return null;
66+
}
67+
} else {
68+
const finalToken = await getAccessToken();
69+
70+
// Timeout is up, so throw if it's still null
71+
if (finalToken === null) {
72+
throw new Error("Failed to refresh access token.");
73+
}
74+
75+
return finalToken;
76+
}
77+
}
78+
79+
let token: AccessToken | null = await tryGetAccessToken();
80+
81+
while (token === null) {
82+
await delay(retryIntervalInMs);
83+
84+
token = await tryGetAccessToken();
85+
}
86+
87+
return token;
88+
}
89+
90+
/**
91+
* Creates a token cycler from a credential, scopes, and optional settings.
92+
*
93+
* A token cycler represents a way to reliably retrieve a valid access token
94+
* from a TokenCredential. It will handle initializing the token, refreshing it
95+
* when it nears expiration, and synchronizes refresh attempts to avoid
96+
* concurrency hazards.
97+
*
98+
* @param credential - the underlying TokenCredential that provides the access
99+
* token
100+
* @param scopes - the scopes to request authorization for
101+
* @param tokenCyclerOptions - optionally override default settings for the cycler
102+
*
103+
* @returns - a function that reliably produces a valid access token
104+
*/
105+
export function createTokenCycler(
106+
credential: TokenCredential,
107+
scopes: string | string[],
108+
tokenCyclerOptions?: Partial<TokenCyclerOptions>
109+
): AccessTokenGetter {
110+
let refreshWorker: Promise<AccessToken> | null = null;
111+
let token: AccessToken | null = null;
112+
113+
const options = {
114+
...DEFAULT_CYCLER_OPTIONS,
115+
...tokenCyclerOptions
116+
};
117+
118+
/**
119+
* This little holder defines several predicates that we use to construct
120+
* the rules of refreshing the token.
121+
*/
122+
const cycler = {
123+
/**
124+
* Produces true if a refresh job is currently in progress.
125+
*/
126+
get isRefreshing(): boolean {
127+
return refreshWorker !== null;
128+
},
129+
/**
130+
* Produces true if the cycler SHOULD refresh (we are within the refresh
131+
* window and not already refreshing)
132+
*/
133+
get shouldRefresh(): boolean {
134+
return (
135+
!cycler.isRefreshing &&
136+
(token?.expiresOnTimestamp ?? 0) - options.refreshWindowInMs < Date.now()
137+
);
138+
},
139+
/**
140+
* Produces true if the cycler MUST refresh (null or nearly-expired
141+
* token).
142+
*/
143+
get mustRefresh(): boolean {
144+
return (
145+
token === null || token.expiresOnTimestamp - options.forcedRefreshWindowInMs < Date.now()
146+
);
147+
}
148+
};
149+
150+
/**
151+
* Starts a refresh job or returns the existing job if one is already
152+
* running.
153+
*/
154+
function refresh(getTokenOptions: GetTokenOptions): Promise<AccessToken> {
155+
if (!cycler.isRefreshing) {
156+
// We bind `scopes` here to avoid passing it around a lot
157+
const tryGetAccessToken = (): Promise<AccessToken | null> =>
158+
credential.getToken(scopes, getTokenOptions);
159+
160+
// Take advantage of promise chaining to insert an assignment to `token`
161+
// before the refresh can be considered done.
162+
refreshWorker = beginRefresh(
163+
tryGetAccessToken,
164+
options.retryIntervalInMs,
165+
// If we don't have a token, then we should timeout immediately
166+
token?.expiresOnTimestamp ?? Date.now()
167+
)
168+
.then((_token) => {
169+
refreshWorker = null;
170+
token = _token;
171+
return token;
172+
})
173+
.catch((reason) => {
174+
// We also should reset the refresher if we enter a failed state. All
175+
// existing awaiters will throw, but subsequent requests will start a
176+
// new retry chain.
177+
refreshWorker = null;
178+
token = null;
179+
throw reason;
180+
});
181+
}
182+
183+
return refreshWorker as Promise<AccessToken>;
184+
}
185+
186+
return async (tokenOptions: GetTokenOptions): Promise<AccessToken> => {
187+
//
188+
// Simple rules:
189+
// - If we MUST refresh, then return the refresh task, blocking
190+
// the pipeline until a token is available.
191+
// - If we SHOULD refresh, then run refresh but don't return it
192+
// (we can still use the cached token).
193+
// - Return the token, since it's fine if we didn't return in
194+
// step 1.
195+
//
196+
197+
if (cycler.mustRefresh) return refresh(tokenOptions);
198+
199+
if (cycler.shouldRefresh) {
200+
refresh(tokenOptions);
201+
}
202+
203+
return token as AccessToken;
204+
};
205+
}

0 commit comments

Comments
 (0)